Backup & restore
Protect and recover control-plane state: the database, secrets and TLS material on the cm-state volume.
The control plane's state is small and quick to protect. Almost all of it lives on a single Docker volume, so a good backup is mostly a matter of copying that volume while the service is quiescent.
What to back up
| Item | Back up? | Why |
|---|---|---|
cm-state volume | Always | Database, auth secret, TLS certificates and keys, ACME state |
.env beside your compose file | Always | VEIL_KEY, AGENT_TOKEN and image tag pins — not stored in any volume |
cm-webroot volume | Optional | Large but reproducible: re-publish builds from the release pipeline |
cm-openresty volume | No | Holds the rendered edge config, which Render & Reload regenerates from the database |
The load-balancer config is derived, not source
The rendered configuration lives on its own volume, cm-openresty, mounted at the
edge's conf.d. That is a durable location rather than an ephemeral one, but it is
still derived: every line comes from the Cluster and FleetHost rows, so restoring
the database and running Render & Reload reproduces it exactly. Skip it in the
backup — and do not hand-edit it, because the next render overwrites the file.
Taking a backup
Stop the manager
The database is SQLite. Stopping the container guarantees a consistent copy and takes only a few seconds; the fleet keeps serving browsers while the control plane is down.
cd /opt/veil-cluster
docker compose stop cluster-managerArchive the state volume
Run a throwaway container that mounts the volume and writes a tarball to the current directory:
docker run --rm \
-v cm-state:/state:ro \
-v "$PWD":/backup \
alpine tar czf "/backup/cm-state-$(date +%F).tar.gz" -C /state .The volume name has no project prefix
compose.yaml pins the volumes as cm-state, cm-webroot and cm-openresty
deliberately, so they survive re-creation under a different project name. Do not
reach for the veil_cm-state form Compose would otherwise generate: Docker does not
error on a volume that does not exist, it creates an empty one — so a
mistyped name produces a valid, empty tarball, and the restore below reports success
while writing nothing. Verify rather than assume.
Add the release webroot only if you cannot easily re-publish it:
docker run --rm \
-v cm-webroot:/webroot:ro \
-v "$PWD":/backup \
alpine tar czf "/backup/cm-webroot-$(date +%F).tar.gz" -C /webroot .Verify the archive is not empty
This is the step that makes a rename fail loudly instead of silently. The database is the one file whose absence means the backup is worthless:
tar tzf "cm-state-$(date +%F).tar.gz" | grep -q './data/cluster-manager.db' \
&& echo "backup contains the database" \
|| { echo "EMPTY OR WRONG VOLUME — check the volume name" >&2; exit 1; }Worth running in whatever schedules the backup, not just by hand. docker volume ls
shows the names this install actually has if the check fails.
Include the environment file
.env holds your organization key and the updater-agent token. It is not on any
volume, so copy it alongside the archive:
cp .env "./env-$(date +%F).bak"Restart and encrypt
Bring the manager back up, then encrypt the archives before they leave the host. They contain private keys and your organization key in plaintext.
docker compose start cluster-manager
gpg --symmetric --cipher-algo AES256 "cm-state-$(date +%F).tar.gz"Treat backups as secrets
A cm-state archive contains the auth secret, TLS private keys and session tokens.
Anyone holding it can impersonate your control plane. Encrypt it at rest and restrict
who can read the offsite copy.
Restoring
Stop the service
Restoring over a running instance will corrupt the SQLite database.
docker compose stop cluster-managerReplace the volume contents
Decrypt, then unpack into a freshly emptied volume:
gpg --decrypt cm-state-2026-07-18.tar.gz.gpg > cm-state.tar.gz
docker run --rm \
-v cm-state:/state \
-v "$PWD":/backup \
alpine sh -c 'rm -rf /state/* /state/.[!.]* 2>/dev/null; tar xzf /backup/cm-state.tar.gz -C /state'That command empties the volume first, so run it against the right name. A typo
creates a new empty volume, unpacks into it, exits 0, and leaves the real state
untouched — the restore looks like it worked and changed nothing. Confirm afterwards:
docker run --rm -v cm-state:/state:ro alpine ls -l /state/data/cluster-manager.dbRestore .env in the same step if you are rebuilding the host from scratch.
Start and verify
The manager applies any pending database migrations in-process on boot. Confirm it came up before sending traffic:
docker compose up -d cluster-manager
curl -fsS http://127.0.0.1:4180/api/system/versionRender & Reload
The load-balancer config is derived state. Apply it once so OpenResty matches the restored database, then check the fleet page shows every worker healthy. See Operating the cluster.
Next steps
- Upgrades & rollback — restoring a database after a bad migration.
- Configuration reference — every path and volume.