Skip to main content

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:

  1. 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).
  2. 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 sets SECRETS_MASTER_KEY_PREVIOUS and later retires it), keep the key that matches each dump alongside it — don't rely on whatever value happens to be in your current .env at restore time. Store it in a secrets manager or password vault, not only inside a copy of .env sitting next to the SQL dump.
  3. The .env file — 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, default 30s).
  • 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

  1. 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
  2. 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 -v removes the stack's named volumes, including postgres_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 redis

    Load the dump into it:

    set -a
    source .env
    set +a

    docker compose exec -T postgres psql -U "$POSTGRES_USER" "$POSTGRES_DB" \
    < planeon-2026-07-12.sql
  3. Restore .env and SECRETS_MASTER_KEY — overwrite the working .env with the backed-up copy that matches this dump (or at minimum confirm SECRETS_MASTER_KEY in the current .env is the exact value that was active when the dump was taken).

  4. Confirm the schema is current before starting the app services:

    docker compose run --rm migrate status

    Every migration must report Applied. A restored dump already carries its own goose_db_version row, so this is usually already current; if you're restoring onto an older PLANEON_VERSION than the dump was taken on, apply any outstanding migrations first with docker compose run --rm migrate.

  5. Start the rest of the stack:

    docker compose up -d

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

Wrong key, not a graceful degrade

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.