Skip to content

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.

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.

Terminal window
$ 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 database

That 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 encrypted file your target names needs three entries — the two you named, plus the repository key:

Terminal window
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 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:

schedulewindowgenerations kept
0 2 * * *7d8
0 */6 * * *7d29
*/5 * * * *7d2017

ob backup prune prints the number it is using before it costs you storage.

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.

Terminal window
$ ob backup status database
service database
repository s3://onebox-backups/shop_database
recoverable to 2026-08-19T17:31:44Z or later, as far as the archived WAL reaches
history 2026-08-12T02:00:11Z onwards; declared window 7d: covers the declared window
data loss at most 15m declared; the write-ahead log is archived continuously and every drift in that is listed above
BACKUP COMPLETED FROM WAL
base_000000010000000000000007 2026-08-19T02:00:09Z 000000010000000000000007

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

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.

Terminal window
$ ob backup verify database
✓ verify database archive

A repository with a hole in it fails and names the range:

Terminal window
$ 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 recoverable

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

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:

Terminal window
→ 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 here

By 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/7513211627332151223

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

Terminal window
$ 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.

Terminal window
$ ob backup restore database --to 2026-08-19T17:31:44Z --confirm database

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

Terminal window
$ ob backup disable database --confirm database

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

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.