Backups · verified restores
A backup you have not restored is a hypothesis.
Most teams discover their backup strategy is fiction during the incident that needed it. The dump was truncated, or it restored into an empty schema, or the passphrase only existed on the machine that died. This is how Omazy CX backs up its system of record, what it deliberately refuses to back up, and the weekly drill that restores the real artifact into a throwaway container and proves it against the same golden schema the migration CI gate uses.
Section 01 · the honest starting point
There were none. That is the smaller of the two problems.
Before this work there was no crontab, no timer, and no off-host copy of a 255 MB production database holding every workspace, conversation and billing row on the platform. That is the easy problem: it is visible, and it takes a day to fix.
The harder problem is the one that replaces it. A team writes a nightly pg_dump, watches it produce a file, and stops thinking about it. The job reports success every night for a year. Then the disk dies, and the dump turns out to have been truncated since March, or it restores into a schema with no rows, or it is encrypted with a passphrase that only ever existed on the machine that just died.
Every one of those failures reports success right up until the moment you need it. So the design goal here was not "produce a dump on a schedule". It was: at every point, prefer a loud failure over a quiet file.
Section 02 · scope
Backing up derived data is a way of hiding from the question.
Four data stores run behind the platform. Only two are backed up, and the exclusions took more thought than the inclusions.
| Store | Method | Reasoning |
|---|---|---|
Postgres · omazy_middleware | pg_dump -Fc, TOC-verified | The system of record. Workspaces, apps, agents, conversations, customers, billing. |
ClickHouse · omazy_analytics | native BACKUP DATABASE | Append-only usage and telemetry events. Not reconstructible from anywhere else. |
OpenSearch | excluded | Rebuildable by re-indexing from Postgres and object storage. Adding it needs a container restart for a static path.repo setting. |
Redis | excluded | Session state and metering counters. A daily reconcile job rebuilds counters from ClickHouse, so the loss window is small by design. |
ClickHouse · system | excluded | Internal telemetry: metric_log, query_log and friends. Larger than the business data, and worth nothing in a recovery. |
The test is simple: can this be reconstructed from something else you already hold? Search indexes can, by re-indexing. Cache counters can, from the event stream that produced them. Conversations cannot. Neither can append-only usage events, which is why the analytics database is in scope even though it is three orders of magnitude smaller than the search index.
The number that looked wrong
The ClickHouse instance holds 883 MiB of parts, but the backup came out at 252 KB. That gap is exactly the shape of a silent partial failure, so it was worth checking rather than celebrating. It turned out to be correct: 868 MB of it is ClickHouse's own metric_log and asynchronous_metric_log, internal telemetry with no TTL. The actual business data is 224 KiB. Restoring a database's own performance metrics into a recovered system buys nothing.
Excluding something is a claim you should be able to defend
"Rebuildable" is easy to assert and expensive to be wrong about. For the search index the claim is concrete: every document in it was written from a row in Postgres or an object in storage, both of which are backed up, and there is a re-index path that already runs. If that ever stops being true, the exclusion stops being valid, which is why it is written down rather than assumed.
Section 03 · the artifact
The dump does not count until something has read it back.
Postgres is captured with pg_dump -Fc, the custom format. It is compressed by default and it is the only format pg_restore can do selective or parallel restores from. Plain SQL looks friendlier in a text editor and is worse in every way that matters at 03:00.
flowchart TB
CRON["cron · every 6h at :30"] --> PG["pg_dump -Fc omazy_middleware"]
PG --> TOC{"pg_restore --list
reads it?"}
TOC -->|"no"| FAIL["FAIL · Slack alert
this is not a backup"]
TOC -->|"yes"| CH["ClickHouse
BACKUP DATABASE omazy_analytics"]
CH --> ENC{"passphrase set?"}
ENC -->|"yes"| GPG["gpg AES256"]
ENC -->|"no"| WARN["log: not encrypted"]
GPG --> UP
WARN --> UP
UP{"bucket configured?"} -->|"yes"| R2["upload to R2"]
UP -->|"no"| LOCAL["keep local copy
and say so"]
Immediately after writing the file, the job reads its table of contents back with pg_restore --list. This costs milliseconds and it is the single highest-value line in the script, because it converts the most dangerous failure mode, a corrupt artifact that exists, into an ordinary alert.
# A dump that pg_restore cannot read is worse than no dump,
# because it looks like success. Verify the TOC before it counts.
if ! docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" -d "$PG_DB" \
-Fc --no-owner --no-acl > "$out"; then
FAILURES+=("postgres dump failed")
return 1
fi
if ! docker exec -i "$PG_CONTAINER" pg_restore --list < "$out" >/dev/null 2>&1; then
FAILURES+=("postgres dump is not readable by pg_restore")
return 1
fi Why ClickHouse is not tarred
Copying the data directory of a running ClickHouse is not a backup. Parts can be mid-merge when the copy walks past them, and what you get is a torn snapshot that mounts fine and is missing rows. The native BACKUP DATABASE command takes a consistent view instead. It needs an allowed path configured in the server config, which is a one-line change and much cheaper than discovering the torn-snapshot problem during a restore.
Encryption is not optional, but the job still runs without it
These dumps contain conversations, contacts and payment profiles. They are encrypted with a symmetric AES256 passphrase before upload. If no passphrase is configured the job continues and logs not encrypted on every artifact, because a platform with unencrypted backups is in a worse place than one whose backup job refused to start and got fixed the same morning.
Section 04 · the part that matters
Once a week, we actually restore it.
A backup nobody has restored is a guess with a filename. Every Sunday a second job takes the most recent dump, restores it into a throwaway container, and refuses to pass unless three separate things hold.
flowchart TB
A["latest dump on disk"] --> B["gpg decrypt if needed"]
B --> C["throwaway postgres:16 container"]
C --> D["pg_restore"]
D --> E["pg_dump --schema-only
same sed normalisation as CI"]
E --> F{"byte-identical to
schema.golden.sql?"}
F -->|"no"| X1["FAIL · Slack"]
F -->|"yes"| G{"do load-bearing
tables have rows?"}
G -->|"no"| X2["FAIL · an empty DB
also matches a schema"]
G -->|"yes"| OK["PASS · container destroyed"]
The first check is that pg_restore can read the artifact end to end. The second is that the restored schema matches the repository's golden schema byte for byte. The third is the one people skip.
An empty database also matches a schema
A structural comparison passes perfectly against a restore that produced every table and not one row. That is a real failure mode: a dump taken with the wrong flags, or a restore that silently stopped after the DDL. So the drill also asserts that load-bearing tables came back populated, and treats an empty workspaces table as a hard failure rather than a curiosity.
# Structural check is not enough: an empty database also matches
# the schema. Assert the tables that would make a restore worthless
# if they came back bare.
for t in workspaces omazy_apps users chat_sessions messages; do
n=$(psql -tAc "SELECT count(*) FROM ${t}" 2>/dev/null || echo "ERR")
[ "$n" = "ERR" ] && fail "table ${t} missing from the restore"
[ "$t" = "workspaces" ] && [ "$n" = "0" ] && fail "workspaces restored empty"
done Here is the first run against a real dump. The omazy_apps count matches production exactly, which is the difference between "a file was produced" and "the data is recoverable":
[verify] verifying /home/ubuntu/backups/20260801-024022/postgres-omazy_middleware-20260801-024022.dump [verify] restoring… [verify] schema matches golden [verify] workspaces: 50 rows [verify] omazy_apps: 129 rows [verify] users: 498 rows [verify] chat_sessions: 457 rows [verify] messages: 2307 rows [verify] restore verification PASSED
Section 05 · one definition of correct
The backup and the migrations answer to the same file.
The schema the drill compares against is not written for the drill. It is db/schema.golden.sql, the same baseline the migration CI gate diffs against when it runs every migration up, then all the way down, then up again.
flowchart LR
MIG["db/migrations/**
125 files"] --> GATE["migration CI gate
up · down · up"]
GATE --> GOLD[("db/schema.golden.sql")]
DUMP["nightly pg_dump"] --> DRILL["weekly restore drill"]
DRILL --> GOLD
GOLD --> TRUTH["one definition of
a correct schema"]
subgraph BOTH["both questions, one answer"]
Q1["are the migrations sound?"]
Q2["does the backup restore?"]
end
This falls out of a property worth designing for: a check is only as trustworthy as the thing it compares against. A restore drill with its own private expectation of the schema is a second source of truth that will drift from the first, and the drift will be discovered during a recovery. Pointing both at one file means "the migrations are sound" and "the backup restores" cannot disagree without something going red.
It also has to be enforced in the boring details. Both the drill and the CI job normalise the dump through an identical sed chain that strips pg_dump version banners, because otherwise a patch-release difference between two machines shows up as schema drift and trains everyone to ignore the alert.
Pin the client, not just the server
The first version of the local tooling shelled out to whatever pg_dump the developer had installed. Against a Postgres 16 server, a local Postgres 15 client emitted a 3,900-line diff and reported it as schema drift. The fix is to run the dump inside the container, so the client version always tracks the server image, which is what CI does by installing a pinned client package.
Test against the version you actually run
The CI gate was pinned to Postgres 15 while production ran 16.14. Both majors happened to emit an identical schema dump, so nothing was broken, but verifying rollback against a different major than production is a trap waiting for a migration that uses version-specific syntax. The pin now tracks production, and the comment in the workflow says why so it does not drift back.
Section 06 · recovery point
At this size, incremental buys you nothing you actually want.
"Incremental backups" is usually reached for as a storage optimisation. It is worth doing the arithmetic before inheriting that instinct.
A full compressed dump of this database is about 68 MB. Thirty of them is roughly 2 GB, which on commodity object storage costs around three cents a month. There is no storage pressure here, so incremental saves nothing meaningful. What it actually buys is a shorter recovery point: how much data you lose when the disk dies.
| Approach | You lose | Cost to build |
|---|---|---|
| Daily full dump | up to 24h | trivial |
| Full dumps every 6h | up to 6h | trivial |
| Full + WAL archiving | minutes, point-in-time | a real project, plus its own restore runbook |
For a product where customers are having live conversations, losing a full day of them is the part worth thinking hardest about. Running the same simple job every six hours instead of once a day cuts the worst case by four, costs pennies, and adds no new machinery. Continuous WAL archiving is the correct answer if six hours is still too wide, but it is a separate project with its own restore runbook, and pretending a cron job is equivalent to it would be the actual mistake.
# BEGIN omazy-cx-backup # Backups every 6h at :30 (off-peak, clear of apt-daily timers). 30 */6 * * * /home/ubuntu/backups/bin/backup.sh >> /home/ubuntu/backups/cron.log 2>&1 # Weekly restore drill, Sundays 04:15 UTC. 15 4 * * 0 /home/ubuntu/backups/bin/backup-verify.sh >> /home/ubuntu/backups/cron.log 2>&1 # END omazy-cx-backup
Section 07 · the decisions that survive contact
Small choices that decide whether this still works in a year.
Retention lives on the bucket
Pruning old backups from the script means the host can delete its own history. If that host is compromised, or a bug loops the prune, the backups go with it. Retention belongs in bucket lifecycle rules, which the machine being backed up has no authority to change. Local copies are a convenience cache with a three-day window, not the backup.
The destination needs its own credential
Reusing the application's object-storage keys is convenient and wrong. A destination the application can also delete from is not a backup, it is a second copy inside the same blast radius. The bucket gets a token scoped to itself, and nothing else holds it.
Store the passphrase somewhere else
An encryption passphrase that lives only on the server you are trying to recover from is not a passphrase, it is a delayed data-loss event. It belongs in a password manager, off the host, before the first encrypted backup is written rather than after.
Degrade, do not abort
Upload is opt-in. With no bucket configured the job still produces verified local dumps and logs that it skipped the upload, instead of dying on an unset variable. The critical half works from day one; the remaining step is visible in the log rather than hidden behind a job that never ran.
A bug worth keeping in the comments
The first version logged to stdout. A helper function returned a filename on stdout too, so every caller that captured that filename also swallowed the log line, and the not encrypted warning silently disappeared from the console output. It was still in the log file, which is the worst case: present enough to feel covered, absent where anyone would look.
# Logs go to STDERR on purpose. Helpers like maybe_encrypt return a
# filename on stdout, so anything that also logs to stdout gets swallowed
# by the caller's command substitution and warnings vanish silently.
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG_FILE" >&2; } The fix is one character. The reason it is worth a comment is that the same shape recurs in any shell script that mixes return values and diagnostics on one stream, and warnings that vanish are the ones you find out about last.
Section 08 · restoring
Into a scratch database, never straight over production.
The recovery path is deliberately the same one the weekly drill walks, so it is exercised fifty-two times a year rather than discovered under pressure.
# 1. Pull the artifact aws s3 cp s3://omazy-backups/omazy-cx/TS/postgres-omazy_middleware-TS.dump.gpg . \ --endpoint-url https://ACCOUNT.r2.cloudflarestorage.com # 2. Decrypt gpg --batch --decrypt --output restore.dump postgres-...dump.gpg # 3. Restore into a SCRATCH database first. Never straight over prod. docker run -d --name restore-target -e POSTGRES_PASSWORD=x -p 55440:5432 postgres:16-alpine docker cp restore.dump restore-target:/tmp/ docker exec restore-target pg_restore -U postgres -d postgres --no-owner --no-acl /tmp/restore.dump
Only after inspecting the scratch restore should anyone consider promoting it. The instinct during an incident is to restore directly over the damaged database, which destroys the evidence of what went wrong and removes the option to change your mind. A scratch target costs one command and preserves both.
Section 09 · implementation surface
Where it lives.
Three shell scripts and a runbook, all in the repository. Nothing here is a hand-typed crontab entry that exists only on the box, because that is how a platform ends up with no backups in the first place.
Scripts
devops/backup.sh: both dumps, TOC verification, optional encryption, upload, local pruning, Slack alert on failuredevops/backup-verify.sh: the weekly drill, schema comparison and row assertionsdevops/backup-install.sh: idempotent deploy plus a cron block written between markers, so re-running updates rather than duplicatesdevops/backup.md: the runbook, including the restore procedure and the known gaps
Operational contract
- Backups every six hours at
:30, drill Sundays04:15UTC - Failures post to the same webhook the platform's own monitoring uses
- Config in a mode-600 env file outside the repository; no credential is ever committed
- A
last-successmarker on disk, so "when did this last work" is answerable without reading logs
Notes & further reading
The honest status: the drill passes, the dumps are verified, and off-host upload is configured but inert until the dedicated bucket and its scoped token exist. Local dumps sitting on the same disk as the database are not a backup, and the runbook says so in those words rather than implying the work is finished.
- Two exclusions are documented as debts rather than decisions: the search index needs a container restart to gain a snapshot repository, and the analytics
systemtables need a TTL before they outgrow the disk the backups land on - The golden schema is regenerated by a make target, so adding a migration without regenerating it fails CI with a readable diff instead of drifting quietly
- Every claim in this article was verified against the live host, including the one that turned out to be wrong: the restored schema was compared byte for byte with a production dump before any of this was called done
Related engineering reading: Background Tasks for the queue platform behind the scheduled work, the Dashboard & Telemetry Engine for what fills the analytics database, and Omazy Webhooks for the retry-and-give-up pattern the alerting borrows from.