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
3.9 KiB
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:
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/nologinsshd 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 theauthorized_keysline withcommand="rsync --server ..."restrictions. .sshmust be mode 700 and owned by the user;authorized_keys600.- 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)
-
Generate a keypair (on the docker host, never in the repo):
ssh-keygen -t ed25519 -N '' -C dorfteich-backup-mirror -f backup_mirror_ed25519 # the .pub half goes into BASEL's authorized_keys (above) -
Put the private key on the
secretsvolume (the sidecar mounts it read-only at/data/secrets; copy through the api container, which mounts it read-write):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 -
Configure the stage
.envand recreate the sidecar: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 # defaultdocker compose up -d backup -
Verify with an on-demand run — the first mirror pins BASEL's host key (
accept-new) into.mirror_known_hostson the backups volume: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 thebackupMirrorFailedalert 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 indocs/operations/restore-runbook.md. - The mirror carries only the raw set files;
status.jsonand the Nextcloud bundles stay local.