Backup and restore
Planeon's system of record is PostgreSQL. Everything the platform knows about
your deployment — pools, sessions, role bindings, audit history, licensing
state, and every stored credential — lives there, sealed at rest with
SECRETS_MASTER_KEY. Back up all three things below together, on the same
schedule, and you can rebuild a working deployment from nothing but the
backup and a running Proxmox cluster.
This runbook assumes the reference production stack
layout: a compose.yaml and an .env file in one directory, with
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, and SECRETS_MASTER_KEY
set in .env. Adjust paths if your install differs.
What to back up
Three things, every time — none of them is optional:
- The Postgres database, via
pg_dump. This is the platform's entire state: desktop pools, managed VM ownership, sessions, RBAC, audit history, license activation state, and every encrypted secret (Proxmox API tokens, guest/domain-join credentials). SECRETS_MASTER_KEY, backed up separately from the database dump. This is the AES envelope key that encrypts every credential above. A database backup is unrecoverable without the exact key that was active when it was taken — restoring a dump under the wrong key doesn't degrade gracefully, it fails outright (see the caveat below). Because the key can change over time (a rotation window setsSECRETS_MASTER_KEY_PREVIOUSand later retires it), keep the key that matches each dump alongside it — don't rely on whatever value happens to be in your current.envat restore time. Store it in a secrets manager or password vault, not only inside a copy of.envsitting next to the SQL dump.- The
.envfile — domain, OIDC client configuration, Postgres credentials, and everything else the stack needs to start. Without it you can still restore the database, but you'll be re-typing every setting by hand.
What's reconstructible from PVE, and what isn't
Not everything Planeon stores carries the same cost if a backup is a few hours or days stale by the time you restore it (a partial-loss scenario is far more common than losing Postgres entirely):
- Reconstructible: managed VM ownership and desktop pool membership are
derived from Proxmox VE inventory. Once you restore Postgres and point
the worker back at a live cluster, the next reconciliation cycle re-scans
PVE and re-syncs pool/VM state — any drift between the backup and the
cluster's actual state self-heals within one reconcile interval
(
POOL_RECONCILE_INTERVAL, default30s). - Not reconstructible — Postgres is the only copy: audit history, RBAC role bindings, license activation state, and every encrypted credential (Proxmox API tokens, guest/domain-join credentials). None of this exists anywhere else. Restoring a dump older than your last configuration change permanently loses everything that happened after the dump was taken — there is no PVE-side or other fallback to replay it from.
Guacamole connection tokens are a special case worth calling out precisely because they don't need backing up: they're short-lived, issued into Redis (not Postgres), and expire on their own. Losing them costs a user one reconnect, nothing more.
Backup procedure
Run this on a schedule (cron, systemd timer, or your backup tooling of
choice) from the directory holding compose.yaml and .env:
# Export .env into the shell so $POSTGRES_USER/$POSTGRES_DB below resolve —
# pg_dump's output redirect happens on the host, not inside the container.
set -a
source .env
set +a
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
> "planeon-$(date +%F).sql"
date +%F produces the real calendar date at run time (for example
planeon-2026-07-12.sql) — timestamp every dump so you can tell backups
apart and pick the right one to restore.
Alongside the dump, copy the current SECRETS_MASTER_KEY value and the
.env file itself to your secure backup storage. Keep all three — the SQL
dump, the master key that was active when it was taken, and .env — as one
matched set.
Restore procedure
-
Stop the app services so nothing writes to Postgres while you restore, and so no service starts against a half-restored schema:
docker compose stop api worker gateway web caddy -
Restore into a fresh Postgres. If you're restoring onto a host that already has a Postgres volume (disaster recovery on the same box), wipe it first —
docker compose down -vremoves the stack's named volumes, includingpostgres_data, so only do this once you're certain you want to overwrite it. Then bring up an empty database:docker compose up -d postgres redisLoad the dump into it:
set -asource .envset +adocker compose exec -T postgres psql -U "$POSTGRES_USER" "$POSTGRES_DB" \< planeon-2026-07-12.sql -
Restore
.envandSECRETS_MASTER_KEY— overwrite the working.envwith the backed-up copy that matches this dump (or at minimum confirmSECRETS_MASTER_KEYin the current.envis the exact value that was active when the dump was taken). -
Confirm the schema is current before starting the app services:
docker compose run --rm migrate statusEvery migration must report
Applied. A restored dump already carries its owngoose_db_versionrow, so this is usually already current; if you're restoring onto an olderPLANEON_VERSIONthan the dump was taken on, apply any outstanding migrations first withdocker compose run --rm migrate. -
Start the rest of the stack:
docker compose up -dConfirm readiness the same way as a fresh install — see Observability → Readiness semantics.
Caveat: a mismatched master key fails loudly
Every app (api, worker, gateway) runs a startup check before serving traffic
that tries to open every stored secret envelope with the configured
SECRETS_MASTER_KEY. If the key doesn't match the one the dump's secrets
were sealed under, startup fails outright with an error naming the table
and row it couldn't decrypt — the process does not start in a degraded or
silently-broken state.
This is intentional and by design: silently running with undecryptable
credentials would be far worse than refusing to start. If you see this
error after a restore, it means the SECRETS_MASTER_KEY in your restored
.env doesn't match the key that was active when the dump was taken —
restore the matching key (and, if you were mid-rotation, its
SECRETS_MASTER_KEY_PREVIOUS counterpart) and restart.
This is the single most common restore mistake: restoring the database
without restoring the master key that matches it. Keep them together in
your backup process, not just in .env — see "What to back up" above.