Backup and Restore Drill for One Installation
Audience: DevOps and QA operators rehearsing recovery of a Stella Ops installation. Owner: DevOps and QA roles. Status: procedure verified against source at 8ae42ab238 on 2026-10-10 (SPRINT_20261007_029 CCP-053) and revised the same day after review: the comparison baseline is recorded after the quiesce, and the commands cover a Windows host, the demo registry overlay and the installation’s own PostgreSQL image and user. It combines the supported cluster tools listed below. It has not been run end to end on a release-bundle installation. The first run is the CA-14 acceptance drill, which waits for an authorized disposable installation.
Retired on 2026-10-10:
drill-backup-restore.shanddrill-backup-restore.ps1. The former pg_dump drill (DEVOPS-OPS-029-01) was removed. On a current cluster, where every service owns its own database (ADR-039), it could not do what it claimed:
- It dumped one database (
-d stellaops, or the database given with--db). It then removed the whole data volume, so every other service database was lost.- Its wipe step always stopped
stellaops-postgresand removedcompose_postgres-data, whichever container--containernamed. It then started postgres from the developer compose file.- Its round-trip check was one statement that reads
authority.*andshared.tenants. No single database holds both schemas (stellaops_authorityandstellaops_platform), so the statement printed nothing before and after. Two empty files compared equal, and the drill reportedpassed.The drill below restores the whole cluster from an application-consistent backup. It restores the file-backed stores and the installation files with it, and it compares every table of every database.
Purpose
Prove that one named installation can be backed up, lost and restored, and that the restored installation serves the same data and verifies the same evidence. The drill covers:
- the PostgreSQL cluster: every service database and role, restored to one recovery point;
- the file-backed stores that database rows refer to, including the Evidence object store;
- the installation files that hold the installation’s secrets and keys.
When to run
- On a disposable installation in an approved window, or as the recovery of a real installation in an approved recovery window. The drill destroys the target installation’s containers and volumes.
- Never against another installation’s containers or volumes. The tools address one installation by explicit container and volume names. Check each name before every destructive step (section 1).
- At least quarterly, and before a release that changes storage layout.
Tools
The scripts are DevOps scripts in devops/compose/scripts/ of the repository. The release bundle does not ship them. Copy them to the operator host and record the repository revision you copied.
| Script | What it does | Refuses (exit code) |
|---|---|---|
backup-postgres.sh | Application-consistent backup of the whole cluster with pg_basebackup. Writes an artifact directory and a .stellaops-backup.json sidecar beside it that records the stop LSN. | Source container missing or not running (2); the replication pre-flight fails (3) |
verify-backup.sh | Checks the artifact against PostgreSQL’s own backup_manifest with pg_verifybackup. | Missing manifest or member (3); checksum or manifest failure (4) |
restore-postgres.sh | Stages the artifact in a target volume. PostgreSQL replays the backup’s WAL on its next start. | The developer estate’s data volumes, unless --i-am-restoring-the-live-estate is given; a volume that a running container mounts; a non-empty volume without --force (all 3) |
pg-census.sh | Writes one sorted database|schema|table|rows line for every table in every database. | Container not running (2) |
The operations runbook describes each tool in detail: backup-restore-ops.md (SP-001A, SP-002, SP-003).
1. Name the installation
A release-bundle installation renders every Docker name from four values in its .env (bundle README.md, troubleshooting entry “The installer stops with ‘this installation would take Docker names that already belong to another installation’”). Work from the installation directory.
The commands are bash. On a Linux host, run them as written. On a Windows host, run them in Git Bash: the scripts in “Tools” handle Git Bash paths, and INSTALL_LABEL below converts the directory to the Windows form that Compose records. The drill has not run on either host yet.
TOOLS=/path/to/copied/devops/compose/scripts # the scripts from "Tools"
cd /path/to/installation
INSTALL_DIR="$(pwd)"
# The directory as Compose records it in each container's working_dir label:
# a Windows path on a Windows host (Git Bash), the same path on Linux
INSTALL_LABEL="$(cygpath -w "$INSTALL_DIR" 2>/dev/null || printf '%s' "$INSTALL_DIR")"
env_value() { grep -E "^$1=" .env | tail -n 1 | cut -d= -f2- | tr -d '\r'; }
VOL_PREFIX="$(env_value STELLA_VOLUME_PREFIX)"; VOL_PREFIX="${VOL_PREFIX:-stellaops}"
ESTATE_PREFIX="$(env_value STELLAOPS_ESTATE_PREFIX)"; ESTATE_PREFIX="${ESTATE_PREFIX:-stellaops}"
PG_USER="$(env_value POSTGRES_USER)"; PG_USER="${PG_USER:-stellaops}"
PG_CONTAINER="${ESTATE_PREFIX}-postgres"
PG_VOLUME="${VOL_PREFIX}_postgres-data"
COMPOSE_FILES=(-f docker-compose.pinned.yml -f secrets/docker-compose.installation.yml)
# An installation made with --with-demo-registry (install.ps1: -WithDemoRegistry) adds the
# demo registry overlay to every compose call; its volume exists only then
if docker volume inspect "${VOL_PREFIX}_demo-registry-data" >/dev/null 2>&1; then
COMPOSE_FILES+=(-f docker-compose.demo-registry.yml)
fi
compose() { docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@"; }
export MSYS_NO_PATHCONV=1 # Git Bash only; it rewrites container paths otherwise
If the installation was started with these variables exported in the shell, a shell value overrode .env; use the values that were in effect. Without the demo registry overlay, compose would leave the demo-registry-data volume out of the inventory, the backup and the removal in section 5.
Confirm the pair before going further. Stop if either check prints something else:
# Must print the value of $PG_VOLUME
docker inspect "$PG_CONTAINER" \
--format '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}'
# Must print the value of $INSTALL_LABEL
docker inspect "$PG_CONTAINER" \
--format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'
2. Inventory what the installation holds
| Class | Where | How it is backed up |
|---|---|---|
| PostgreSQL cluster | volume <prefix>_postgres-data | backup-postgres.sh (section 4, step 3) |
| File-backed stores | the other named volumes of the installation, except caches and plugin scratch | volume archives (section 4, step 4) |
| Installation files | the installation directory | directory archive (section 4, step 5) |
List the file-backed stores from the installation’s own compose model:
FILE_VOLUMES="$(compose config --volumes \
| grep -v -E -- '-plugin-scratch$|cache(-data)?$|^postgres-data$')"
echo "$FILE_VOLUMES"
The filter leaves out volumes whose content the services rebuild: the scanner caches, the agent’s registry cache and the plugin scratch areas. Among the remaining volumes, these hold material that evidence verification depends on (source: docker-compose.pinned.yml of the bundle, devops/compose/docker-compose.evidence.yml, devops/docker/tsa/entrypoint.sh):
| Volume | Mounted by | Holds |
|---|---|---|
evidence-data | evidence-web, evidence-worker at /data/evidence | the Evidence object store (EvidenceLocker__ObjectStore__Kind=FileSystem) |
attestor-proofchain-cas | evidence-web, evidence-worker at /app/data/proofchain-cas | the proof-chain content-addressed store |
tsa-data | tsa at /var/lib/tsa | the local timestamp authority’s root CA, signer key and serial counter, generated on first start. Losing them changes the timestamp trust root. |
exportcenter-object-store-data | offlinekit-web, offlinekit-worker | exported audit bundles |
The installation directory holds .env (database role passwords, client secrets, KEKs), secrets/ (generated keys and certificates, the CA bundles, the agent CA keystore and its passphrase, the installation compose overlay), the generated keys under config/etc/ (listed in the bundle README.md, “Installer-generated secrets and keys”) and state/ (the installer’s records). The cluster stores every service role with the password that .env holds. Restore the directory and the cluster from the same backup set.
The directory archive contains every secret of the installation. Keep it under the same custody as the KEKs. stellaops-backup-crypto encrypt can wrap any archive (backup-restore-ops.md, SP-001 step 4).
3. Record the running installation
Run these while every service runs. Keep every output beside the backup set, never inside the postgres-basebackup-<ts> directory: pg_verifybackup fails on any file its manifest does not list.
BACKUP_DIR=/path/to/backup-set # outside the installation directory
mkdir -p "$BACKUP_DIR/volumes" "$BACKUP_DIR/records"
PG_IMAGE="$(docker inspect "$PG_CONTAINER" --format '{{.Config.Image}}')"
# Identity: bundle manifest, and the image every service runs
sha256sum release-manifest.yaml > "$BACKUP_DIR/records/identity.txt"
compose ps --format '{{.Name}} {{.Image}}' >> "$BACKUP_DIR/records/identity.txt"
docker ps --filter "label=com.docker.compose.project.working_dir=$INSTALL_LABEL" \
--format '{{.Names}}' | xargs -r docker inspect --format '{{.Name}} {{.Image}}' \
>> "$BACKUP_DIR/records/identity.txt"
Record the evidence results. Retrieve and verify representative evidence through Evidence’s own interfaces, as in DR-003 of backup-restore-ops.md: the doctor checks, an exported bundle verified offline, and a capsule replay. Record every result, including those that already fail before the backup. Record one administrator sign-in.
These actions write rows and files: an exported bundle lands in exportcenter-object-store-data, each timestamp advances the serial counter in tsa-data, and a sign-in can write Authority rows. They therefore happen before the quiesce, and the comparison baseline is recorded after it (section 4, step 2).
4. Back up
Quiesce. Stop every service except PostgreSQL, so the database rows and the file stores describe the same moment:
compose stop $(compose config --services | grep -vx postgres)Stopping a service can still write: Valkey, for example, flushes its append-only file on stop. From here until the services start again in step 6, PostgreSQL alone runs: no service writes rows or file-store content, and PostgreSQL’s own data files are covered by the cluster backup, not by the checksum lists.
Record the comparison baseline. The backup captures this quiesced state, so the restored installation must match it exactly (section 7):
# Every table of every database "$TOOLS"/pg-census.sh --container "$PG_CONTAINER" --user "$PG_USER" \ > "$BACKUP_DIR/records/census-before.txt" # Content of every file-backed store (the installation's own PostgreSQL image, no network) for v in $FILE_VOLUMES; do docker run --rm --network none -v "${VOL_PREFIX}_${v}:/v:ro" "$PG_IMAGE" \ sh -c 'cd /v && find . -type f -print0 | sort -z | xargs -0r sha256sum' \ > "$BACKUP_DIR/records/sha256-before-${v}.txt" doneA census or checksum list taken while services run is not a valid baseline: every write between it and the quiesce is in the backup and shows up as a difference after the restore.
Back up and verify the cluster:
"$TOOLS"/backup-postgres.sh --container "$PG_CONTAINER" --user "$PG_USER" \ --out-dir "$BACKUP_DIR" --yes "$TOOLS"/verify-backup.sh --image "$PG_IMAGE" "$BACKUP_DIR"/postgres-basebackup-<ts>Archive each file-backed store:
for v in $FILE_VOLUMES; do docker run --rm --network none -v "${VOL_PREFIX}_${v}:/v:ro" \ -v "$BACKUP_DIR/volumes:/out" "$PG_IMAGE" tar -C /v -czf "/out/${v}.tar.gz" . doneArchive the installation directory:
tar -C "$(dirname "$INSTALL_DIR")" -czf "$BACKUP_DIR/installation.tar.gz" \ "$(basename "$INSTALL_DIR")"Record checksums of the backup set, then start the services again:
(cd "$BACKUP_DIR" && sha256sum installation.tar.gz volumes/*.tar.gz \ > records/backup-set.sha256) compose start
5. Lose the installation (drill only)
compose config --volumes # the volumes the next command removes
compose down -v
docker volume ls --filter "name=^${VOL_PREFIX}_" --format '{{.Name}}' # must print nothing
cd "$(dirname "$INSTALL_DIR")" # Windows refuses to move the current directory
mv "$INSTALL_DIR" "${INSTALL_DIR}.lost-$(date -u +%Y%m%dT%H%M%SZ)"
down -v removes the containers, networks and named volumes that this installation declares, and nothing else. Do not use docker volume prune or docker system prune: they act on every installation on the host.
6. Restore
Restore the installation directory to the same absolute path. The checks in section 1 and the records in section 3 name this path, and each container that Compose creates records it. Another path also works for the installer once no container of the installation exists, as after section 5: it accepts the volumes that the directory’s
state/installation-namesrecords (install.sh,assert_installation_isolated; bundleREADME.md, the same troubleshooting entry as in section 1, on moving an installation). A different path makes the records of this drill harder to compare, so keep the original one.tar -C "$(dirname "$INSTALL_DIR")" -xzf "$BACKUP_DIR/installation.tar.gz" cd "$INSTALL_DIR"Let Compose create the containers and empty volumes without starting anything. A volume created with
docker volume createhas no Compose labels. The installer later refuses such a volume as “not created by docker compose”.compose up --no-start PG_IMAGE="$(docker inspect "$PG_CONTAINER" --format '{{.Config.Image}}')"Stage the cluster. The volume is empty and no container runs, so the script needs neither
--forcenor an override. If it asks for one, stop and check the names again."$TOOLS"/restore-postgres.sh --image "$PG_IMAGE" \ --artifact "$BACKUP_DIR"/postgres-basebackup-<ts> --target-volume "$PG_VOLUME" --yes--imagekeeps the restore on the installation’s digest-pinned PostgreSQL image. Without it, and withoutjqon the host, the script falls back topostgres:18.1, which an offline host may not hold.Extract each file-backed store into its new volume:
for v in $FILE_VOLUMES; do docker run --rm --network none -v "${VOL_PREFIX}_${v}:/v" \ -v "$BACKUP_DIR/volumes:/in:ro" "$PG_IMAGE" tar -C /v -xzf "/in/${v}.tar.gz" doneStart PostgreSQL alone and read its recovery:
compose up -d postgres docker logs "$PG_CONTAINER" 2>&1 \ | grep -E 'backup recovery|consistent recovery state|ready to accept'The LSN in
consistent recovery state reached at <LSN>must equalstopLsnin the artifact’s.stellaops-backup.json. The log says backup recovery, not crash recovery.Take the after-state records before any other service starts, because running services write rows:
"$TOOLS"/pg-census.sh --container "$PG_CONTAINER" --user "$PG_USER" \ > "$BACKUP_DIR/records/census-after.txt" for v in $FILE_VOLUMES; do docker run --rm --network none -v "${VOL_PREFIX}_${v}:/v:ro" "$PG_IMAGE" \ sh -c 'cd /v && find . -type f -print0 | sort -z | xargs -0r sha256sum' \ > "$BACKUP_DIR/records/sha256-after-${v}.txt" doneStart the installation:
compose up -d, then wait untilcompose psreports every service healthy.
7. Verify
diff -u census-before.txt census-after.txtprints nothing. Both records describe a state in which only PostgreSQL ran: the quiesced installation (section 4, step 2) and the restored cluster before any other service started (section 6, step 6).- For each file-backed store,
diff -u sha256-before-<v>.txt sha256-after-<v>.txtprints nothing. - The evidence results from section 3 are the same after the restore, including the ones that failed before. A restore that regenerates or drops records cannot reproduce the failures.
- The administrator signs in. Where an authorized role run exists, each restricted role reaches its workspace.
8. Record the drill
| Item | Record |
|---|---|
| Installation | bundle commit and release-manifest.yaml sha256; installation prefixes; the image of every service (records/identity.txt) |
| Host | CPU, memory, free disk before and after; Docker and Compose versions; scripts’ repository revision |
| Sizes | PostgreSQL data size; the size of each file-backed volume; the size of each artifact |
| Time | wall-clock time of each phase: quiesce, cluster backup, volume and directory archives, loss, each restore step, recovery, all services healthy, first administrator sign-in |
| Operator effort | every command run and every manual decision or correction, counted |
| Outcomes | recovery LSN against stopLsn; census diff; file-store diffs; evidence results before and after; each failure with its exact message |
Measured times describe this installation and its data volume only. Do not quote them as the recovery time of another installation. See “Recovery objectives (measured)” in backup-restore-ops.md.
What the drill does not prove
- Point-in-time recovery. No WAL is archived; the recovery point is the backup’s stop LSN.
- That data content is semantically right. The census compares row counts; WAL replay is what restores content.
- Anything about a different installation, a larger data volume or another host.
- Upgrade compatibility. Restoring a backup into a newer bundle is an upgrade, not a restore. See deployment-upgrade.md, “Release-bundle installations”.
Current coverage and remaining limits (2026-10-20)
Archive source qualified at d8fc7e65d7; re-verify with python devops/compose/scripts/test_backup_evidence.py -v. This does not qualify the complete installation drill described above.
backup.shnow archives PostgreSQL, RustFS, Valkey, Evidence filesystem and proof-chain CAS with full member/source integrity metadata and early missing-source, identity and writer-state refusals. PCD-557 (SPRINT_20261006_110) owns that source repair and the still-open real recovered-subject proof. Its volume tar remains crash-consistent for PostgreSQL; use the application-consistent cluster backup from the same application quiesce. The complete per-volume drill above is still required for stores outside those five sources.- The release bundle ships none of these scripts and no backup or restore procedure of its own.
- No tool records a joint consistency point for the database and the file stores. The quiesce step provides it.
- Compare the frozen file bytes, sidecars and owner identities before any native/portable package read: those reads may materialize a missing object or a successor row. Then repeat the recorded positive/negative owner and independently trusted evidence checks.
Developer compose estate
The developer estate on a workstation uses fixed physical names (stellaops-postgres, compose_postgres-data, compose_evidence-data and others; devops/compose/AGENTS.md). restore-postgres.sh refuses its data volumes unless --i-am-restoring-the-live-estate is given. Do not run this drill on that estate. Restore it only as a recovery, in an approved window.
Cross-references
- backup-restore-ops.md: the backup and restore tools, the measured recovery objectives and the disaster-recovery scenarios.
- deployment-upgrade.md: upgrades, including the release-bundle status.
- ADR-004: forward-only migrations.
docs/runbooks/database/migration-recovery.md: the forward-only migration recovery path.
