dorfteich/deploy/backup-basel.md
Claude Fable 5 52192eb05f
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
Backup mirror to BASEL: rsync of the sets after every successful run (#84)
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00

96 lines
3.9 KiB
Markdown

# Backup mirror to BASEL (issue #84)
Nightly rsync of the Prod backup sets to the private BASEL host over the
WireGuard tunnel (ADR 0015) — the operator-level extra beside the
admin-configured Nextcloud target (#103). The sidecar mirrors the set
files (`db-*.dump`, `files-*.tar.gz`) after every successful local run;
`--delete` keeps the remote retention aligned with the local prune, so
the newest-complete-set guarantee carries over. Mirror outcome lands in
`status.json` (`mirror` block, shown on the admin backup card); failures
alert through the backup failure mail while local backups continue.
## BASEL-side setup (once, as root on BASEL)
A **dedicated user with a home under `/home/`** — deliberately NOT the
Debian `backup` system user (UID 34), whose `/var/backups` home and
`nologin` shell are documented foot-guns in the operator conventions:
```sh
useradd --create-home --shell /bin/bash dorfteich-backup
mkdir -p /home/dorfteich-backup/.ssh
# authorized_keys: the public half of the key generated below
install -m 600 -o dorfteich-backup -g dorfteich-backup authorized_keys \
/home/dorfteich-backup/.ssh/authorized_keys
chmod 700 /home/dorfteich-backup/.ssh
chown dorfteich-backup:dorfteich-backup /home/dorfteich-backup/.ssh
mkdir -p /home/RAID/BACKUPS/dorfteich-prod
chown dorfteich-backup:dorfteich-backup /home/RAID/BACKUPS/dorfteich-prod
chmod 750 /home/RAID/BACKUPS/dorfteich-prod
```
**Pitfalls (learned on the wochenplan setup):**
- The login shell MUST be `/bin/bash` (or `/bin/sh`) — with
`/usr/sbin/nologin` sshd rejects every session, including rsync's.
Security comes from the key-only login, not from nologin; if you want
to lock it down further, prefix the `authorized_keys` line with
`command="rsync --server ..."` restrictions.
- `.ssh` must be mode 700 and owned by the user; `authorized_keys` 600.
- The home directory itself may be root-owned but must not be
group/world-writable (sshd's StrictModes).
## Stack-side setup (per stage that mirrors)
1. **Generate a keypair** (on the docker host, never in the repo):
```sh
ssh-keygen -t ed25519 -N '' -C dorfteich-backup-mirror -f backup_mirror_ed25519
# the .pub half goes into BASEL's authorized_keys (above)
```
2. **Put the private key on the `secrets` volume** (the sidecar mounts it
read-only at `/data/secrets`; copy through the api container, which
mounts it read-write):
```sh
docker compose cp backup_mirror_ed25519 api:/data/secrets/backup_mirror_ed25519
docker compose exec api chmod 600 /data/secrets/backup_mirror_ed25519
shred -u backup_mirror_ed25519
```
3. **Configure the stage `.env`** and recreate the sidecar:
```sh
BACKUP_MIRROR_TARGET=dorfteich-backup@172.30.1.10:/home/RAID/BACKUPS/dorfteich-prod/
BACKUP_MIRROR_SSH_KEY=/data/secrets/backup_mirror_ed25519
#BACKUP_MIRROR_SSH_PORT=22 # default
```
```sh
docker compose up -d backup
```
4. **Verify** with an on-demand run — the first mirror pins BASEL's host
key (`accept-new`) into `.mirror_known_hosts` on the backups volume:
```sh
docker compose run --rm -e BACKUP_RUN_ONCE=1 backup
docker compose exec backup sh -c \
'grep -A4 \"mirror\" /backups/status.json'
# re-run: "transferredFiles": 0 proves idempotency
```
## Behaviour
- Mirror runs after the local prune of every **successful** run (nightly,
manual button, `BACKUP_RUN_ONCE`); a failed local run never mirrors.
- A mirror failure sets `status.json → mirror.lastRun.outcome = "failed"`
(visible on the admin backup card) and sends the `backupMirrorFailed`
alert mail — the local run still counts as succeeded.
- Restoring from the mirror: copy the set pair back into the stack's
backups volume and run `./restore.sh <backup-id>` — same procedure as
the Nextcloud path in `docs/operations/restore-runbook.md`.
- The mirror carries only the raw set files; `status.json` and the
Nextcloud bundles stay local.