Backing up and moving the game data¶
The rules are in ADR-0016: the Azure instance only ever runs temporarily, and the game data moves between Compose, Kubernetes and Azure as a logical dump, one writer at a time.
This page is the how. Everything on it has been run: a full round trip Kubernetes → Azure → Kubernetes → Compose on 2026-09-12, through the two tasks below, with row counts compared at every station and a PIN sign-in on Azure and on Compose. The record is at the end.
The two tasks¶
task db:dump ENV=compose|kubernetes|azure
task db:restore ENV=compose|kubernetes|azure FILE=schmetterpause-….sql
The logic is scripts/db.sh; scripts/db-counts.sql is what gets counted.
A dump writes schmetterpause-<env>-<time>.sql — a plain pg_dump with
--no-owner --no-acl, schema, data and goose_db_version — and next to it
….sql.counts, the row counts of every table. It counts before and after the
dump and refuses if the two differ: somebody entered a result while it ran.
A restore refuses a database that already has players. Otherwise it resets
the schema — the application's own migration has usually created empty tables
already — and loads the dump as the role the application connects as, in one
transaction, so a failure leaves the database as it was. Then it compares the
counts with the dump's .counts, sorted.
Both files are sensitive. They hold display names, PIN hashes and
recovery-code hashes. schmetterpause-*.sql and schmetterpause-*.sql.counts
in the repository root are gitignored and written with mode 600. Never let
one become a CI artefact, and keep the one from a teardown somewhere a person
chooses — once the server is gone, it is the only copy.
First: back up the office¶
The office plays on the local Compose stack — project schmetterpause, volume
schmetterpause_pgdata, shared by task up and task office:up. That volume
is the ranking; the Kubernetes cluster was only ever a trial. task down
deletes it.
Before any work that moves or could touch game data, dump it and prove the dump restores — a backup nobody restored is not a backup:
task db:dump ENV=compose # reads only; the app keeps running
# Probe-restore into a throwaway project with its own volume and no ports.
cat > /tmp/compose.probe.yaml <<'EOF'
services:
db:
ports: !reset []
app:
ports: !reset []
EOF
COMPOSE_PROJECT_NAME=sp-probe COMPOSE_FILE=compose.yaml:/tmp/compose.probe.yaml \
docker compose up --detach --wait db
COMPOSE_PROJECT_NAME=sp-probe COMPOSE_FILE=compose.yaml:/tmp/compose.probe.yaml \
task db:restore ENV=compose FILE=schmetterpause-compose-….sql # "counts match"
COMPOSE_PROJECT_NAME=sp-probe COMPOSE_FILE=compose.yaml:/tmp/compose.probe.yaml \
docker compose down --volumes
down --volumes only ever with the probe project named. Without the variables
it is the office.
task office:backup still writes a plain dump of the same database; db:dump
adds the counts that make a restore checkable.
Compose¶
Whatever docker compose would address from the repository root: the project
named after the directory, or COMPOSE_PROJECT_NAME and COMPOSE_FILE when set.
The database container has to be running.
A restore stops the app first — it migrates on start and serves requests, neither of which belongs in the middle of a restore — and starts it again afterwards, also when the load fails.
Kubernetes¶
Whatever kubeconfig and context kubectl uses. The task names them before it
does anything; check that line.
KUBECONFIG=~/.kube/homerun2-test1 task db:dump ENV=kubernetes
KUBECONFIG=~/.kube/homerun2-test1 task db:restore ENV=kubernetes NAMESPACE=… FILE=…
NAMESPACE defaults to schmetterpause, DB_CLUSTER to schmetterpause-db.
pg_dump and psql run inside the CloudNativePG primary over its local socket,
so no credential leaves the cluster. The load runs behind SET ROLE <owner>:
as postgres, every table would belong to postgres and the application could
not touch them.
A restore refuses while the application's Deployment has replicas. Scaling it is a decision about that environment, so the task leaves it to you:
kubectl -n <namespace> scale deployment/schmetterpause --replicas=0
# restore
kubectl -n <namespace> scale deployment/schmetterpause --replicas=1
A throwaway cluster to restore into, the way the round trip made one:
kubectl create namespace schmetterpause-restore-test
task kcl:secrets NAMESPACE=schmetterpause-restore-test
task kcl:database PROFILE=existing-secrets -- -D config.namespace=schmetterpause-restore-test \
| kubectl apply -f -
It came up healthy in under a minute. Delete the namespace afterwards; the volume goes with it.
Azure¶
Needs an applied instance (task tf:apply) and a live login (task tf:login).
The task reads the server, the major version and the credentials from the
Terraform state and the two .tfvars files.
task db:dump ENV=azure # before task tf:destroy
task db:restore ENV=azure FILE=schmetterpause-….sql # after task tf:apply
It does not connect from your machine. Both directions run in a one-off Azure Container Instance, for three reasons found the hard way:
- The office network blocks outbound port 5432. A firewall rule on the
server for your address does nothing against that.
curl -s --max-time 10 http://portquiz.net:5432/ >/dev/null && echo open || echo blockedshows it. - Docker Hub refuses anonymous pulls from Azure (
RegistryErrorResponse). The container runsghcr.io/cloudnative-pg/postgresql:<major>instead — the image the kcl module runs the database with, at the server's major. - It gets its own resource group,
<name_prefix>-dbjob-rg.azurermrefuses to delete a resource group that holds resources it does not manage (prevent_deletion_if_contains_resourcesdefaults totrue), so anything left inschmetterpause-rgwould breaktf:destroy. The job group is deleted whenever the task ends, successful or not. If a run is killed hard and the group survives, the next run says so and stops.
The container reaches the server through the existing "allow Azure services"
rule. A dump comes back through az container logs, gzipped and base64-encoded
between markers with a sha256 that the task checks. A restore goes in through an
Azure Files share in the job group, uploaded over HTTPS. The password and the
storage key only ever sit in a mode-600 file in a private temporary directory,
deleted as soon as the container exists.
After a restore the task restarts the application's revision, so its init
container runs migrate up against the restored schema — which matters as soon
as the image is newer than the dump.
On 2026-09-12 a dump took 156 seconds and a restore 119, most of it pulling the image and creating the storage account.
Versions¶
A dump only ever goes into the same or a newer PostgreSQL major, and the same or a newer application version. Everything is on 18: Compose, the kcl default (#221), Azure (#224), and the CloudNativePG cluster on homerun2-test1 since its in-place upgrade on 2026-09-12 (see below).
That cluster's major is pinned in stuttgart-things/stuttgart-things, as
database.imageName in
clusters/labul/vsphere/platform-sthings/argocd/homerun2-test1/schmetterpause.yaml,
not in the argocd catalog. The catalog default in stuttgart-things/argocd
apps/schmetterpause/install/values.yaml is still 17, and the PR previews,
which set no image, get it.
What travels, and what does not¶
- Players sign in once. A new environment is a new host, so no browser is
recognised. PINs and recovery codes come along in the dump, and signing in
with them works — checked on Azure and on Compose.
SP_SESSION_KEYonly matters behind an address that stays the same. - Pending and disputed matches travel as they are. Somebody still has to confirm them on the other side.
- Flexible Server's own backups do not. They are deleted with the server.
- Row order can differ. The Alpine images sort text bytewise even though
they report
en_US.utf8— musl has no collation — while Flexible Server sorts by locale. That is why the counts are compared sorted, and it is worth knowing when a restored instance lists names in a different order.
The round trip on 2026-09-12¶
The data was the tournament from 2026-09-11 — the Kubernetes trial cluster has
no PINs, so it could not show a sign-in. Every arrow is one task db:dump or
task db:restore.
| Station | PostgreSQL | Result |
|---|---|---|
| Tournament dump from the first Azure teardown | 17.11 | taken by hand that morning, 43,049 bytes |
| → Kubernetes, fresh test cluster | 18.6 | restored, counts match |
| Kubernetes → dump | 18.6 | 43,171 bytes, counts equal before and after |
| → Azure, fresh instance | 18 | restored in 119 s, revision restarted, healthy |
| Azure → dump | 18.6 | 43,146 bytes in 156 s, checksum ok |
| → Kubernetes, a second fresh test cluster | 18.6 | restored, counts match |
| Kubernetes → dump → Compose, app running | 18.6 | app stopped, restored, counts match, app started, healthy |
| PIN sign-in | on Azure and on Compose, player signed in in both logs |
The sorted counts hash to the same value at every station: 7 players,
9 identities, 14 credentials, 34 matches (33 confirmed, 1 pending), 35 sets,
66 rating-history rows, 2 tournaments with 12 entries, migrations at
20260904120000.
Before it started, the office Compose database was dumped and probe-restored: 15 players, 66 matches, counts matching. The round trip never touched it.
The Kubernetes trial cluster to 18, 2026-09-12¶
A different kind of move: no dump and restore, but CloudNativePG 1.30's offline
in-place major upgrade (pg_upgrade --link), triggered by changing the
Cluster's imageName from :17 to :18 (stuttgart-things/stuttgart-things#2899).
The prerequisites from the CNPG documentation held: both images on the same
Debian base, the source at 17.11 (at least 17.6 is required), no extensions.
The backup came first: task db:dump ENV=kubernetes, probe-restored into an
isolated Compose project with matching counts. Then:
| Time | What happened |
|---|---|
| 12:45 | Argo synced the new imageName; the Cluster went to Upgrading Postgres major version |
| 12:46 | the major-upgrade job ran pg_upgrade on the 18 image |
| 12:47 | the instance started on 18; Cluster in healthy state at 12:47:49 |
About two minutes offline. The server reports 18.6, a dump taken right after
has the same counts as the one taken before — 6 players, 12 matches, 20
rating-history rows, migrations at 20260904120000 — and the app's /readyz
answered 200.
Had it failed, the way back was the same line set to :17: CNPG restarts on
the old major, because the upgrade does not modify the old data.
Not covered¶
- Emptying a database that has players. There is no task for it, on purpose: a restore replaces everything, and the moment somebody wants that for the office is a decision to make by hand, after a verified backup.
- The catalog default.
apps/schmetterpause/installinstuttgart-things/argocdstill defaults to PostgreSQL 17, so a new PR preview starts on 17. Raising it moves every consumer without an override at once, which makes it a change of its own.