---
title: "Back up a database"
summary: "How to declare a backup policy and a repository, what `ob backup enable` actually does, and why a backup nobody has restored is a hypothesis."
description: "Continuous off-host backups for PostgreSQL, and how to prove they restore."
status: shipped
read_when:
  - "Protecting a managed PostgreSQL service"
  - "Working out what a declared backup policy does and does not do on its own"
  - "Proving a repository can actually be recovered from"
---
```yaml
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

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.

```console
$ 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 credential file

The encrypted file your target names needs three entries — the two you named,
plus the repository key:

```sh
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

`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

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.

```console
$ 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.

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

```console
$ ob backup verify database
✓ verify database archive
```

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

```console
$ 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.

## 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:

```console
→ 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.

## Prove it restores

```console
$ 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.

> **The scheduled drill verifies; it does not restore**
>
> The timer installed from `drill.schedule` runs an archive-continuity check, not a
> full recovery: the orchestration lives in `ob`, and Onebox puts no agent on your
> host. Run `ob backup drill` from CI on the cadence your policy declares. A backup
> nobody has restored is a hypothesis.

## Recovering

```console
$ 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.

> **After losing the volume or host, select the old generation**
>
> A database rebuilt from an empty volume is a new cluster with a new identifier.
> `ob backup enable` therefore starts a new generation instead of risking a WAL
> collision with the old one. It also restores the runtime and credentials needed
> to inspect the off-host repository when the original host state is gone.
>
> Status discovers every generation still present in that repository:
>
```console
$ ob backup status database
service     database
repository  s3://onebox-backups/shop_database/clusters/7520844209557584421
generation  7513211627332151223
generation  7520844209557584421
> ```
>
> Prove and restore the old history by selecting its identifier explicitly:
>
```console
$ ob backup drill database --generation 7513211627332151223
$ ob backup restore database --generation 7513211627332151223 --confirm database
> ```
>
> A successful restore records that recovered cluster as the service's current
> generation, so subsequent backup commands continue on the restored history. Use
> `--generation legacy` for a repository created before cluster-scoped generations
> were introduced.

## Stopping

```console
$ 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.

## 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`](/reference/fields/services) and
[`backup_targets`](/reference/fields/backup_targets).