ops: bring the database stack into the repo, and make its image portable - #24
Conversation
The Postgres half of production existed only as files in one directory on one host. Nothing in git described WAL archiving, the retention step, the dump sidecar, or the image wal-g runs in - so the runbook could restore a backup while nothing could recreate the thing that takes them. That gap silently owns the recovery point objective: archive_timeout=60 is the whole of it, and it was one untracked line. The image was also pinned to a single aarch64 asset with no checksum, which builds fine on another architecture and then fails to execute. It now selects by TARGETARCH, verifies the published sha256, and fetches in an alpine stage so no package manager or download tool reaches the final image. The base is pinned to a Debian suite rather than floating on postgres:17. glibc owns text collation, initdb bakes it into every text index, and moving a data directory between glibc versions can reorder them. trixie matches the running cluster. Studio is folded in behind a compose profile with restart:"no", so it stays available without being able to start on its own or come back after a reboot. Its keys move to the env file rather than being committed. The dev compose ran postgres:16 against a production running 17.
Bumping compose.yml to postgres:17 breaks every existing self-host that follows the documented update path: docker compose pull && up -d recreates the database against a data directory initialised by 16, and it exits with "database files are incompatible with server". Reverted to 16, behind POSTGRES_IMAGE_TAG for anyone who wants to move after dumping and reloading. The wal-g image now builds on either base - PG_BASE selects it, and gcompat is installed when the base is alpine, since wal-g publishes glibc binaries only. Alpine is 496 MB against 742 MB, and the full archive path works on it: a fresh musl cluster archiving with wal-push, a base backup, WAL written after that backup, then a fetch and replay into a second container that came back with all 6000 rows including the 1000 written post-backup. The default stays glibc because the running cluster is glibc and the two sort text differently, which initdb bakes into every text index. The measured difference is in .env.db.example so the choice is made with it in view.
|
Checked against the README and the docs site before merging, and one thing in here did break them — fixed in 3c33d90. The Reproduced it directly (init a volume with 16-alpine, start 17-alpine on it). Reverted to 16, now behind Then ran the documented quickstart end to end in isolation, with the current Alpine, as far as it goes safely
Tested the whole archive path on musl rather than just a restore: a fresh alpine cluster with The default stays glibc because production is glibc, and the two sort text differently:
initdb bakes that ordering into every text index, so it is a dump-and-reload decision, not an image swap. Both rows are in Everything else in the stack is already alpine: the server runtime and its builder, the dump sidecar, rclone 1.69 (Alpine 3.21), and the wal-g fetch stage. |
The Postgres half of production existed only as files in one directory on one host. A grep for
wal-gacross this repo matched exactly one file — the restore runbook added last week. So the runbook could restore a backup while nothing in git could recreate the thing that takes them.What was missing, concretely:
wal_level=replica,archive_mode=on,archive_command=wal-g wal-push %p,archive_timeout=60,max_wal_senders=3. The recovery point objective is that fourth flag and nothing else, and it was one untracked line.wal-g delete retain FULL 14in the base-backup loop. That is the fix for the incident where the bucket reached 8.29 GiB backing a 54 MB database. Rebuild the stack from memory without it and the regression returns silently, visible only as a bill.R2_*→AWS_*mapping WAL-G needs.Portability
The old Dockerfile hardcoded
wal-g-pg-20.04-aarch64with no checksum. On an amd64 host that image builds successfully and then cannot execute wal-g — the failure arrives at the first archive push, not at build time.It now picks the asset from
TARGETARCH, verifies the.sha256upstream publishes, and does the download in analpinestage, so the final image carries no package manager, no curl, and no apt lists. Verified both legs: the amd64 fetch produces anEM_X86_64ELF, and a native build runswal-g v3.0.8.Base pinned to a suite
postgres:17floats between Debian releases. glibc owns text collation, initdb bakes it into every text index, and moving a data directory across glibc versions can reorder them. Pinned totrixie, which is what the running cluster is on (glibc 2.41, checked against the live container).For the record: an
alpinebase builds and passes a full restore drill withgcompat, and would save about 250 MB. Not taken — musl collation differs from glibc, so it cannot be swapped under an existing data directory, only across a dump and reload.Studio
Folded in behind a compose profile with
restart: "no", soup -dnever starts it and a reboot cannot bring it back:Confirmed that
config --serviceslists four services by default and six with the profile. Its demo keys move to the env file instead of being committed.Also
The dev
compose.ymlranpostgres:16-alpineagainst a production on 17.Nothing here touches the running stack: it keeps its own copy of these files and its own image. Adopting the repo copy is a separate, optional step, and does not require rebuilding the image.