Backup & restore
What to back up, how to restore it, and how long the estate keeps its own data. This guide expands the restore side that running.md → Volumes and running.md → Retention name but do not walk through.
Three named volumes hold everything worth saving, and they are not equal. One is the estate itself; losing it is total data loss. The other two hold a single generated secret each, and losing either is a recoverable inconvenience — the service mints a new one on next boot. Know which is which before you plan a backup.
| Volume | Service | Mount | Holds | Cost of losing it |
|---|---|---|---|---|
pgdata | postgres | /var/lib/postgresql/data | the entire estate — subjects, observations, spans, every declared row | total data loss |
web-state | web | /app/state | session signing key | all sessions invalidated; web regenerates a new key on boot |
worker-state | worker | /app/state | prober SSH private key | provisioned vantages must re-install the new public key |
The service names and volumes above are the real ones from
docker-compose.yml; the commands below use them
verbatim.
Backing up pgdata
The database holds no secret (running.md → Where secrets live),
but it holds everything else. Take a logical dump with pg_dump inside the
running postgres container — it is transactionally consistent without stopping
the stack, so web and worker keep serving while it runs.
-U verge -d vergeare the shipped defaults (POSTGRES_USER/POSTGRES_DB). If you overrode either in.env, substitute your values.--clean --if-existsmakes the dump self-restoring: it drops each object before recreating it, so a restore does not fight leftover rows.-Tdisables TTY allocation so the redirect captures clean SQL.
For a smaller, faster-restoring artifact use the custom format instead, written to a bind-mounted path so the file lands on the host:
Store the dump off the host. A backup that lives only on the machine it protects is not a backup.
Restoring pgdata
Into the running stack
A plain-SQL dump taken with --clean --if-exists restores straight through
psql:
For a custom-format (-Fc) dump, pipe it through pg_restore instead:
web applies goose migrations on boot, so restoring a dump from an older
schema and then starting the current image lets web migrate it forward. Restore
first, then docker compose up -d.
Into a clean volume
To rebuild from scratch — corruption, a moved host, docker compose down -v — let
compose recreate an empty pgdata, then load the dump before anything writes to
it:
docker compose down -v deletes all three volumes, so this path also discards
web-state and worker-state. That is usually fine — see below — but if you
backed them up, restore them before the first web/worker start.
The two state volumes
Neither state volume is in the database and neither is worth a scheduled backup on its own — each holds one secret the owning service regenerates. Back them up only to avoid the disruption that regeneration causes.
web-state — session signing key. Generated by web on first boot. Lose the
volume and web mints a new key: every existing session cookie stops verifying,
so every signed-in operator is logged out and signs in again. No data is lost
and no reconfiguration is needed.
worker-state — prober SSH private key. Generated by worker at prober
provisioning; only the public half ever leaves the instance
(prober.md). Lose the volume and worker generates a new keypair —
but every prober host still trusts the old public key in its
authorized_keys, so pushes fail until you re-provision each vantage and install
the new public key on it. This is the one state loss with real operational cost,
proportional to how many probers you run.
If you want to spare yourself either, snapshot the volumes while the stack is down.
Find their exact names first — Docker prefixes them with the compose project
(the directory name unless you set COMPOSE_PROJECT_NAME):
Then tar each through a throwaway container (substitute the names you saw):
Restore is the same command with tar xzf into an empty volume before the owning
service starts.
The pre-upgrade backup drill
running.md → Upgrades flags this, and it is the one time a
pgdata backup is non-negotiable: web applies new migrations before the new
code serves traffic, and a schema change is not always cleanly reversible. Take
the dump first, then upgrade:
If the upgrade misbehaves, you can roll the database back by restoring that dump into a clean volume (see above) and pinning the previous image. The state volumes need no pre-upgrade snapshot — a session re-login and, at worst, prober re-provisioning are recoverable without one.
Retention — how long the estate keeps its own data
Backups protect against loss you did not choose. Retention is loss you do
choose: two sweeps inside worker that retire aged rows on a dial you set at
Settings (the delivery tab; the form posts to POST /settings/retention, and
the whole Settings page is admin-only). Both dials ship at 0 — unbounded — v1
grows the corpus without limit until you turn a dial up. This matters to backups
because it decides how much there is to back up, and because it is the only
supported way to delete estate data.
The two dials are independent and floored differently:
- Dispatch retention (
dispatch_cadence_multiple) — retires expired operational dispatch rows and nothing else; the sweep's data layer exposes no observation, span or batch method, so it structurally cannot touch measured data. Stated as a multiple of the slowest enabled scan's cadence, not a day count.0is unbounded; any positive value below 2 cadences is rejected, because below that the coverage layer cannot answer whether the slowest scan ran. - Observation (evidential) retention (
observation_currency_days) — retires only evidential observations, in whole days. An observation is live while it is within the bound of the tightest scan covering its timeline — derivations read the live tier and it is never discarded — and evidential once past that bound, read by no derivation. Only evidential rows are eligible, and only past your dial.0is unbounded; a positive value below the tightest bound in force is rejected as a no-op (the whole corpus already outlives it).
A live observation is never retired no matter how low the dial goes: the delete query evaluates each row's own per-timeline bound. The dial only governs how long evidential rows — the record of what was once measured — are kept beyond that.
Checklist — what to back up, how often, how to test a restore
| What | How | How often |
|---|---|---|
pgdata (the estate) | pg_dump as above, stored off-host | on a schedule matching your tolerance for lost days, and always before an upgrade |
web-state | volume tar, optional | rarely — regeneration only forces a re-login |
worker-state | volume tar, optional | before any host move if you run several probers, to avoid re-provisioning them |
Test the restore, not just the backup. A dump you have never restored is a guess. Periodically:
- Restore the latest dump into a throwaway stack — a separate directory, so
COMPOSE_PROJECT_NAMEdiffers and it gets its own volumes. docker compose up -dand confirmwebcomes up healthy and the migrations apply cleanly (docker compose logs web).- Sign in and spot-check that subjects, observations and spans are present.
- Tear it down with
docker compose down -v.
If step 2 or 3 fails, the backup was not one. Find out on a rehearsal, not during an incident.
See also
- running.md → Volumes and → Retention — the operational digest this guide expands.
- prober.md — how the prober SSH keypair is generated and where the private half stays.
- troubleshooting.md — when a restore or a boot does not go to plan.