VeilBrowserDocs

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

data/cluster-manager.db
.auth-secret
setup-token
ItemBack up?Why
cm-state volumeAlwaysDatabase, auth secret, TLS certificates and keys, ACME state
.env beside your compose fileAlwaysVEIL_KEY, AGENT_TOKEN and image tag pins — not stored in any volume
cm-webroot volumeOptionalLarge but reproducible: re-publish builds from the release pipeline
cm-openresty volumeNoHolds 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.

terminal
cd /opt/veil-cluster
docker compose stop cluster-manager

Archive the state volume

Run a throwaway container that mounts the volume and writes a tarball to the current directory:

terminal
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:

terminal
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:

terminal
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:

terminal
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.

terminal
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.

terminal
docker compose stop cluster-manager

Replace the volume contents

Decrypt, then unpack into a freshly emptied volume:

terminal
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:

terminal
docker run --rm -v cm-state:/state:ro alpine ls -l /state/data/cluster-manager.db

Restore .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:

terminal
docker compose up -d cluster-manager
curl -fsS http://127.0.0.1:4180/api/system/version

Render & 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

Was this page helpful?

On this page