Restic-based backup container, encrypted daily backups to a Synology NFS share, 14-day retention, Uptime Kuma alerting.
8.8 KiB
NAS Backup for Postgres + TDLib State — Design
Date: 2026-07-23 Status: Approved for planning
Summary
Add a dedicated backup container to the DragonsStash stack that takes daily,
encrypted, deduplicated backups of the two things that can't be regenerated —
the Postgres database (inventory/STL metadata, users, everything the app
manages) and the two TDLib state volumes (Telegram session/auth state for the
worker and bot) — and ships them to a Synology NAS over NFS. STL archive
contents themselves are explicitly out of scope: they only live on this host
temporarily and are not backed up.
Backups are stored via restic, which provides encryption-at-rest, block-level dedup, and retention pruning natively, so no custom encryption or pruning scripts need to be written or maintained.
Context
Current state (as of this design):
- Production stack runs from
/opt/stacks/DragonsStash/docker-compose.ymlon this Dockge-managed host, pulling prebuilt images fromgit.samagsteribbe.nl. Thedocker-compose.ymlin this repo is the build/dev reference and should be kept in sync. - Named volumes in use:
postgres_data(Postgres 16 data directory),tdlib_state(worker's TDLib session),tdlib_bot_state(bot's TDLib session),tmp_zipsandmanual_uploads(both transient, explicitly out of scope here). - No backup mechanism, NFS mount, or host cron currently exists anywhere in this deployment.
- The host already runs Uptime Kuma (used here for backup alerting) and Loki (container logs are presumably already collected there).
Requirements
- Daily backup of the Postgres database and both TDLib state volumes.
- Backups stored on a Synology NAS via NFS, not on local disk.
- 14-day retention, oldest snapshots pruned automatically.
- Backups encrypted at rest (Postgres dumps and TDLib session files both contain sensitive material — password hashes, Telegram API secrets, live session state).
- Postgres backups must be transactionally consistent regardless of live app traffic. TDLib state backups are best-effort (see Decisions below) — this is an accepted trade-off, not a defect.
- Alert (via existing Uptime Kuma) if a backup run fails or doesn't happen.
- No new host-level state (no
/etc/fstabentries, no host crontab) — the backup mechanism should be a container, consistent with how everything else on this host is deployed and versioned. - No new privileged access — specifically, the backup container must not have Docker socket access or any ability to control sibling containers.
Decisions
- NFS mounted via Docker's native NFS volume driver (
driver_opts: type: nfs), not a host-level mount. Keeps all backup-related state inside the compose file instead of split across host config. - Restic, not hand-rolled tar+age+find. Restic already solves encryption, dedup, and retention correctly; hand-rolled scripts would be reinventing that logic with more room for bugs.
- TDLib state is tarred live (best-effort), not paused. Pausing the worker/bot for a clean snapshot would require mounting the Docker socket into the backup container so it could stop/start sibling containers — a real privilege escalation (a compromised backup container could then control any container on the host). The downside of a best-effort tar is bounded: worst case, a bad TDLib restore means redoing the Telegram SMS auth flow, which is the same outcome as having no backup at all. That bounded, low-severity downside doesn't justify the privilege escalation.
- Fixed-time cron (
crond), not a sleep-loop. Asleep 86400loop drifts on every container restart; a real crontab entry fires at a fixed time of day regardless of restarts, for negligible extra complexity. - Restore is manual, not automated. A script capable of restoring can overwrite live state; that should always require a human deliberately running it, not run unattended.
Design
New service: backup
Added to both /opt/stacks/DragonsStash/docker-compose.yml (production) and
this repo's docker-compose.yml (dev/build reference).
- Image: custom,
FROM alpine:3.20,apk add --no-cache restic postgresql16-client curl tzdata dcron tar bash. No dependency on app source — independent Dockerfile, e.g.backup/Dockerfile. - Scheduling:
crond -fin the foreground as the container's entrypoint, with a crontab installed at build time:(daily dump/backup at 03:00, weekly repo integrity check at 04:00 Sunday).0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1 0 4 * * 0 restic check >> /proc/1/fd/1 2>&1restic initruns once at container startup (entrypoint, beforecrondstarts), swallowing the "already initialized" error on subsequent container (re)starts. - Network:
internalonly — reachesdragonsstash-db:5432forpg_dump. No ports exposed. - Volumes:
tdlib_state:/data/tdlib-worker:rotdlib_bot_state:/data/tdlib-bot:ronas_backups:/backups, a named volume defined with:nas_backups: driver_opts: type: nfs o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100" device: ":${NAS_EXPORT_PATH}"
- New
.enventries:NAS_HOST,NAS_EXPORT_PATH(NFS share details),RESTIC_PASSWORD(repo encryption key),KUMA_PUSH_URL(Uptime Kuma push monitor URL). All four are inputs to gather during implementation, not hardcoded. restart: unless-stopped, noprivileged, no Docker socket mount.
backup.sh
set -euo pipefail
trap 'curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=down" \
--data-urlencode "msg=$BASH_COMMAND failed"' ERR
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-Fc -f /tmp/dragonsstash.dump
tar czf /tmp/tdlib.tar.gz -C /data tdlib-worker tdlib-bot
restic backup /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
restic forget --keep-daily 14 --prune
rm -f /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=up" \
--data-urlencode "msg=OK"
PGPASSWORD and RESTIC_REPOSITORY=/backups/restic-repo are set as
container environment variables (from .env), not inline in the script.
Data flow
crond (daily 03:00)
→ pg_dump (consistent snapshot via Postgres MVCC) → /tmp/dragonsstash.dump
→ tar tdlib_state + tdlib_bot_state (best-effort, live) → /tmp/tdlib.tar.gz
→ restic backup (encrypt + dedup) → NFS-mounted repo on Synology NAS
→ restic forget --keep-daily 14 --prune
→ curl Uptime Kuma push monitor (up on success, down + reason on any failure)
Restore (manual, documented procedure — not scripted/automated)
restic -r /backups/restic-repo restore latest --target /tmp/restore
pg_restore -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
--clean --if-exists /tmp/restore/tmp/dragonsstash.dump
# untar /tmp/restore/tmp/tdlib.tar.gz back into the tdlib_state /
# tdlib_bot_state volumes (via a throwaway container mounting both)
Alerting
- One Uptime Kuma Push monitor, created manually in the existing Kuma instance, with an expected heartbeat interval of ~26 hours (slack past the 24h schedule so one slow run doesn't false-positive). Whatever notification channels are already configured on that monitor fire automatically — no new alerting integration.
backup.shpushesstatus=upon success andstatus=down(with the failing command inmsg) on any failure, via theERRtrap.- Container logs go to stdout, collected the same way every other container's logs already are on this host.
Testing
This repo has no automated test framework (documented convention: manual testing). For this infra change:
- After deploy: manually run
docker exec dragonsstash-backup /backup.shonce, confirm a snapshot appears (restic snapshots), confirm the Kuma monitor goes green. - Restore drill (once, during setup): actually restore the dump into a scratch Postgres and untar the TDLib archive into scratch volumes, to prove the backup is really restorable. Not automated or recurring for now.
Out of scope / non-goals
- Backing up
tmp_zipsormanual_uploads— both transient by design. - Automated/scheduled restore testing.
- Backing up any other stack on this host (this design is DragonsStash-only, though the pattern — Docker-native NFS volume + restic — could be reused for other stacks later).
- Pausing worker/bot for a guaranteed-consistent TDLib snapshot (see Decisions).
Files touched
docker-compose.yml(this repo) and/opt/stacks/DragonsStash/docker-compose.yml(production) — addbackupservice,nas_backupsvolume.backup/Dockerfile— new.backup/backup.sh— new.backup/crontab— new..env.example/.env— addNAS_HOST,NAS_EXPORT_PATH,RESTIC_PASSWORD,KUMA_PUSH_URL.