Back up a database
services: database: driver: postgres version: "18" persistence: {mode: durable} backup: target: offsite recovery_kind: pitr max_data_loss: 15m schedule: {cron: "0 2 * * *", timezone: UTC} drill: schedule: {cron: "0 4 * * *", timezone: UTC}
backup_targets: offsite: kind: s3-compatible endpoint: https://objects.example.net bucket: onebox-backups failure_domain: {identity: provider-b/eu-central-1} credentials: file: secrets/backup.env access_key_entry: BACKUP_ACCESS_KEY_ID secret_key_entry: BACKUP_SECRET_ACCESS_KEY encryption: {pitr: client-side}Two blocks: the intent sits on the service, the destination is declared once at the top level and referenced by name, so several services can share a repository and changing its endpoint is one edit.
Declaring is not enabling
Section titled “Declaring is not enabling”A backup block is a request. Until ob backup enable succeeds, the service
renders as an ordinary unprotected server — ob validate accepts the policy and
nothing archives.
$ ob backup enable database✓ protected image postgres:18✓ backup runtime wal-g v3.0.8 (aarch64)✓ service database→ backup schedule: ob-backup-shop-production-database-backup at 0 2 * * *→ backup schedule: ob-backup-shop-production-database-verify at 0 4 * * *✓ backup databaseThat one command pins the image by registry digest, stages a checksum-verified wal-g onto the host, decrypts and installs the destination credentials, restarts the server with WAL archiving on, installs the timers, and takes the first base backup. It is not finished until that base backup exists — WAL archiving with nothing to replay onto recovers nothing, and reporting success there would be telling you the database is protected at the moment it is not.
The restart is a real restart. Enabling is a maintenance action, not a configuration change.
The credential file
Section titled “The credential file”The encrypted file your target names needs three entries — the two you named, plus the repository key:
BACKUP_ACCESS_KEY_ID=...BACKUP_SECRET_ACCESS_KEY=...OB_REPOSITORY_KEY=$(openssl rand -hex 32)OB_REPOSITORY_KEY is a 32-byte key read as hex, so exactly 64 hex characters.
A passphrase-shaped value is refused before anything restarts. You do not stage
this file yourself: ob backup enable decrypts it and installs it mode-0600 on
the host.
Retention is derived
Section titled “Retention is derived”retention has two floors and Onebox keeps whichever is larger — the number of
generations, or enough of them to span the window at your schedule’s rate:
| schedule | window | generations kept |
|---|---|---|
0 2 * * * | 7d | 8 |
0 */6 * * * | 7d | 29 |
*/5 * * * * | 7d | 2017 |
ob backup prune prints the number it is using before it costs you storage.
What the repository can recover
Section titled “What the repository can recover”Every figure comes from the repository, not from the project — and the policy is printed beside it, because “is my promise being kept” is the question you came with.
$ ob backup status databaseservice databaserepository s3://onebox-backups/shop_databaserecoverable to 2026-08-19T17:31:44Z or later, as far as the archived WAL reacheshistory 2026-08-12T02:00:11Z onwards; declared window 7d: covers the declared windowdata loss at most 15m declared; the write-ahead log is archived continuously and every drift in that is listed above
BACKUP COMPLETED FROM WALbase_000000010000000000000007 2026-08-19T02:00:09Z 000000010000000000000007A repository younger than its window says so rather than implying otherwise:
shorter than the declared window — this repository does not reach that far back yet. That is not a fault on a service enabled this morning, but it is not the
promise either, and only the report can tell you which one you are looking at.
Status also asks the server whether it is still archiving. A correct wal-g
binary on the host says the tooling is in place; it says nothing about
archive_mode still being on or archive_command still being the one Onebox
installed. Anything that drifted is printed as a drift line above the figures.
The archive check
Section titled “The archive check”A base backup plus a gapped WAL stream recovers to the backup and no further —
a nightly snapshot wearing the label of point-in-time recovery. ob backup verify is the check for that, and the timer from drill.schedule runs the same
one unattended.
$ ob backup verify database✓ verify database archiveA repository with a hole in it fails and names the range:
$ ob backup verify database✗ ob: the archived WAL has 1 gap(s) — 00000001000000000000002B..00000001000000000000002B missing_lost. A base backup plus a gapped WAL stream recovers to the backup and no further, so any point after the first gap is not recoverableA segment the server may still be uploading is not counted as a gap; it resolves itself and is reported on the next run if it does not.
Moving the repository
Section titled “Moving the repository”Editing a target’s bucket or endpoint, or pointing the service at a different
target, takes effect at the next ob backup enable — and Onebox says what that
means:
→ database now archives to backup target "s3://elsewhere/shop_database". What "s3://onebox-backups/shop_database" holds is untouched, but this repository starts from the backup being taken now, so the declared recovery window begins hereBy default, everything else — a restore, a drill, status, retention — keeps reading the repository the service was actually bound to, so editing a target cannot silently point a recovery at a repository the history is not in. Status, drill, and restore can also select an older cluster generation explicitly.
A repository also moves when the database is replaced, without anything in the project changing. Each repository is scoped to the cluster that wrote it, using PostgreSQL’s own system identifier:
s3://onebox-backups/shop_database/clusters/7513211627332151223That is not decoration. Every fresh cluster starts its write-ahead log at
000000010000000000000001, so a new database archiving where an old one left
off would write object names that already exist with different contents. The
archiver refuses to overwrite them, and because PostgreSQL archives strictly in
order, one refused segment stops the chain permanently. Scoping by cluster means
a rebuilt database simply begins its own history, and the previous one stays
exactly where it is.
ob backup status prints the repository and every cluster generation it finds
there, which is how you tell two histories apart when a service has been rebuilt.
Prove it restores
Section titled “Prove it restores”$ ob backup drill database✓ recovery: fetch base backup✓ recovery: replay to the newest recoverable point✓ recovery: verify the recovered cluster answers✓ drill passed: database recovered to the newest recoverable point from base_000000010000000000000007 and answered (4 tables in public schema). Nothing was changed.A drill recovers into a throwaway volume, proves the cluster opens and answers a query, then discards it. The live service is never touched. It runs the same code as a real restore and stops before the last step — a drill exercising its own path would prove the drill works, not the backups.
Recovering
Section titled “Recovering”$ ob backup restore database --to 2026-08-19T17:31:44Z --confirm databaseThe recovered cluster is built beside the live one and has to start, promote and
answer a query before anything touches the running database — so a repository
that cannot recover fails while the database it would have replaced is still
serving. The data being replaced is copied aside under a dated volume name and
retained after restore. Only a later explicit ob destroy --volumes removes
that safety copy.
The service name is typed back with --confirm because a recovery has no plan
for the approval flow to bind to, so the guard is the name of the thing being
replaced.
Without --to, recovery goes to the newest recoverable point. With --to,
Onebox picks the newest base backup that finished at or before your point —
replay only moves forward, so a later base can never reach an earlier moment. A
point older than everything in the repository is refused, and the refusal tells
you when the oldest backup finished.
Stopping
Section titled “Stopping”$ ob backup disable database --confirm databaseArchiving stops, the timers go, the service restarts unprotected, and the destination credentials are removed from the host. The repository is not touched — but reading or recovering from it needs backup enabled again, because the tooling and credentials that reach it live in the protected service.
Re-enabling the same database returns to the same repository: the cluster’s identifier has not changed, so neither has its generation. Re-enabling after the volume has been replaced does not, for the reason in the caution above — recover first if what you want is the old history.
What is not covered
Section titled “What is not covered”Only the postgres driver has an executable contract, on versions 17 and 18.
Every other driver refuses a backup policy at validate rather than accepting
one it cannot honour. A workload’s own volume is never copied anywhere — ob doctor names every workload holding durable data.
Full field list: services and
backup_targets.