ADR-0027: The single host's backups¶
Status: Accepted, amended by ADR-0031 Date: 2026-10-12 Deciders: Alex Nodeland
Builds the rest of phase 2 of RFC-0004: the single host's backups and its restore drill. Records what the RFC left to the build, and where it departs from it.
Context¶
RFC-0004 backs up a single host with pgBackRest and ClickHouse's native BACKUP ... TO S3, to an S3-compatible bucket off the host, and checks them with a restore drill that a timer runs and Grafana watches. CI runs the drill against MinIO. Building it found that pgBackRest speaks only HTTPS to S3, and the stack's MinIO, which Langfuse uses, serves plain HTTP.
Decision¶
The bucket and its keys¶
- The bucket is the
BACKUP_S3_*settings: an endpoint reached over HTTPS, path-style, its port, bucket, region and keys. pgBackRest and ClickHouse read the same settings. - Its certificate is always checked, against
BACKUP_S3_CA_FILE, a CA bundle on the host: the host's own by default, or a store's own CA. pgBackRest names it withrepo1-storage-ca-file; ClickHouse adds it withcaConfig, beside the system's, leaving its verification mode as it is, since that setting is global. - PostgreSQL's backups are encrypted before they leave the host, with
aes-256-cbcandBACKUP_CIPHER_PASS, whichmake envgenerates. Losing it loses the backups: it is kept with the rest of.env, off the host. ClickHouse's backups leave the host unencrypted by stackr, and rely on the bucket's own server-side encryption, which the guide tells operators to turn on. - Secrets in the containers' environment. PostgreSQL's entrypoint writes the passphrase and the bucket's secret key into pgBackRest's configuration, then unsets them, so PostgreSQL and its children don't inherit them. They stay in the container's own settings, which
docker inspectshows to anyone who can run it on the host, as every service's do: that is the host's root, which holds.envanyway.
PostgreSQL¶
- Built with pgBackRest (
deploy/host/postgres), at a pinned package version, from the imagecompose.yamlpins, whichscripts/check-configholds it to. - Its entrypoint writes pgBackRest's configuration from the settings, readable by
postgresalone, and turns on WAL archiving only when a bucket is set: without one, nothing would take the WAL, and it would fill the disk. - Archiving starts with the first
make backup, which makes pgBackRest's stanza and then a marker in the data directory,stackr-archiving. Until the marker exists, the archive command lets each WAL file go at once: there is nothing yet to restore it onto. That also keeps the image's first start quick: its entrypoint runs a temporary server with the same settings, and PostgreSQL archives before it stops, which, against a bucket with no stanza, waited on every WAL file's retries until the health check gave up. - A full backup each Sunday, before the drill, and a differential on the other nights. pgBackRest keeps two full backups and the differentials and WAL they need, so it can restore to any point in the last one to two weeks.
pgbackrest checkruns first, so a backup fails when WAL archiving does.
ClickHouse¶
- A
backupuser does the backups and the drill, from inside the container only. It may read and back up Langfuse's database, use thebackupsnamed collection and S3, read the system tables, and own the drill's databases; it can't write Langfuse's tables. Langfuse's own user can't reach the bucket. Restoring onto a new host is an operator's task, as ClickHouse's admin, with the bucket's keys in the statement, which ClickHouse masks in its logs;RESTORE DATABASE defaultrestores into the emptydefaultdatabase, with noCREATE DATABASE. - The named collection's URL and keys come from the environment (
from_env), so no key is written in a statement. - Each nightly backup is whole, of Langfuse's database and the drill's, as
clickhouse/<UTC time>. The bucket's own lifecycle rule expires them.
The scripts, the timers and the alerts¶
deploy/host/backup(make backup;BACKUP_TYPEchooses the kind) anddeploy/host/restore-drill(make restore-drill) each send their result to the Collector from an exit trap,stackr.backup.successandstackr.restore_drill.success, 1 or 0, so a run that fails part way still says so.- The drill proves the backups restore, with a row written just before. The latest backups must be under 36 hours old. A sentinel row goes into PostgreSQL, whose WAL is switched and must be archived within two minutes, and into ClickHouse, whose drill database is then backed up. PostgreSQL's latest backup and the WAL since are restored with
docker run, in a container from the same image with no volume, and must have every table the live databases have, and the sentinel. ClickHouse's latest nightly backup is restored asrestore_drill, and must have rows in every table where the live one has them; the drill's own backup is restored asrestore_sentinel, and must have the sentinel; both are dropped first and after. - The drill's limits: each restore checks first that there is room for it, and the waits for recovery and archiving are bounded.
- systemd timers: the backups nightly, and the drill each Sunday after them, each with a start timeout.
bootstrapinstalls them. - Four alerts, provisioned by the host's overlay only: no backup has succeeded in 36 hours; the latest backup failed; the drill hasn't passed in eight days; the latest drill failed. Before a bucket is set, the first and third fire. No contact point is provisioned: Grafana lists firing alerts, and an operator adds one.
CI¶
The single host's run backs up to a MinIO of its own, over HTTPS, with a certificate the job makes and names in BACKUP_S3_CA_FILE, so pgBackRest and ClickHouse check it as they would any store's. It waits for MinIO, makes the bucket, runs make backup restore-drill, and make smoke PROFILES=drill finds the drill's success in Prometheus.
Where this departs from RFC-0004¶
- ClickHouse is restored beside the live database, not into a new container. The restore reads the same backup a new server would, through the same collection, with no second ClickHouse to configure. It costs the live server, while it runs, the disk of a second copy of Langfuse's database (checked first), the IO to write it, and the memory of the restore. The
backupuser needsALLon the drill's databases, and onlySELECTandBACKUPon Langfuse's. - CI's drill uses a MinIO of its own, not the stack's. pgBackRest needs HTTPS, and the stack's MinIO serves Langfuse over HTTP.
- RFC-0004's design says "full and incremental backups" and "the latest backups into new containers". The design above is what the host does: weekly full and nightly differential backups for PostgreSQL, a new container for PostgreSQL's restore, and the live server for ClickHouse's. The RFC stays as accepted.
Options considered¶
| Option | For | Against |
|---|---|---|
| A MinIO of the job's own, with a certificate it makes and the recipe checks (chosen) | The recipe is unchanged: CI fills in BACKUP_S3_*, as an operator does |
A step of CI scaffolding |
| HTTPS on the stack's MinIO | One MinIO | Langfuse, and every client of the S3 port, move to HTTPS and a private CA |
| Turning certificate checks off for a test bucket | Simplest | A setting that turns security off, one typo from production |
| ClickHouse restored as another database (chosen) | No second server, nor its configuration | The restore's disk, IO and memory, on the live server |
| A new ClickHouse container | Apart from the live one | Its users, named collection and disk to set up again |
Consequences¶
- Easier: a host's backups and their drill run from timers, are encrypted and checked, and Grafana says when either stops passing.
- Harder:
.envholds the key to the backups, and has to be kept off the host. - Harder: ClickHouse's backups are whole each night; the bucket's lifecycle rule has to expire them.
- Revisit: incremental ClickHouse backups, if whole ones grow too large.