Six tasks: backup image, backup.sh, repo compose wiring, CI build step, production compose wiring (gated on NAS/Kuma details from the user), deploy + restore-drill verification.
20 KiB
NAS Backup for Postgres + TDLib State Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a backup container to the DragonsStash stack that takes daily, encrypted, deduplicated backups of the Postgres database and both TDLib state volumes, and ships them to a Synology NAS over NFS.
Architecture: A small Alpine-based image (restic + postgresql16-client + curl + dcron) runs as its own compose service. A crontab fires backup.sh daily at 03:00, which dumps Postgres, tars the TDLib volumes, hands both to restic backup against an NFS-backed Docker volume, prunes with restic forget --keep-daily 14, and reports success/failure to an Uptime Kuma push monitor. Matches the existing worker/bot pattern: build context in the repo's docker-compose.yml, prebuilt image in /opt/stacks/DragonsStash/docker-compose.yml, built and pushed by .drone.yml.
Tech Stack: Alpine 3.20, restic 0.16, postgresql16-client, dcron, bash, Docker Compose NFS volume driver.
Global Constraints
- Retention:
restic forget --keep-daily 14 --prune(14-day window, per approved spec). - Schedule: daily backup at 03:00, weekly
restic checkat 04:00 Sunday. - No Docker socket mount, no
privileged: true— the backup container must not be able to control sibling containers. - No host-level mount — NFS access only via Docker's native
driver_opts: type: nfsvolume, never/etc/fstab. - Encryption and retention are restic's job — no hand-rolled
age/gpg/find -mtimelogic. - TDLib volumes are tarred live (best-effort) — never pause
worker/botfor the backup. - Restore is a manual, documented procedure only — never scripted/automated.
Required user input before Task 5 can run: NAS_HOST and NAS_EXPORT_PATH (the Synology NFS share details) and a Kuma Push-monitor URL (KUMA_PUSH_URL, created manually in the existing Uptime Kuma instance, ~26h expected heartbeat interval). Tasks 1–4 need none of these and can proceed immediately; do not substitute placeholder values for them in Task 5 — stop and ask the user instead.
Task 1: Backup image (Dockerfile + entrypoint)
Files:
- Create:
backup/Dockerfile - Create:
backup/entrypoint.sh - Create:
backup/crontab
Interfaces:
-
Produces: a buildable image tagged
dragonsstash-backup:testlocally, with/entrypoint.shasENTRYPOINT,/backup.shpresent at the image root (written in Task 2 — this task only needs theCOPYline and a placeholder-free stub isn't acceptable, so create an empty‑body-but-realbackup/backup.shhere containing just#!/bin/bash+exit 0, and Task 2 replaces its contents),restic,pg_dump/pg_restore,curl,tar,bash,dcronall onPATH. -
Consumes: nothing from earlier tasks.
-
Step 1: Write
backup/backup.shstub
#!/bin/bash
set -euo pipefail
exit 0
- Step 2: Write
backup/entrypoint.sh
#!/bin/bash
set -euo pipefail
if ! restic snapshots >/dev/null 2>&1; then
restic init
fi
exec crond -f -l 2
- Step 3: Write
backup/crontab
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
- Step 4: Write
backup/Dockerfile
FROM alpine:3.20
RUN apk add --no-cache restic postgresql16-client curl tzdata dcron tar bash
COPY backup/backup.sh /backup.sh
COPY backup/entrypoint.sh /entrypoint.sh
COPY backup/crontab /etc/crontabs/root
RUN chmod +x /backup.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
- Step 5: Build the image
Run: cd /home/sam/Documents/DragonsStash && docker build -t dragonsstash-backup:test -f backup/Dockerfile .
Expected: build completes with Successfully tagged dragonsstash-backup:test (or Buildkit's equivalent final naming to docker.io/library/dragonsstash-backup:test done), no errors.
- Step 6: Verify the tools are present
Run: docker run --rm dragonsstash-backup:test restic version && docker run --rm dragonsstash-backup:test pg_dump --version
Expected: restic 0.16.x ... and pg_dump (PostgreSQL) 16.x printed, both commands exit 0.
- Step 7: Verify the entrypoint initializes an empty repo and starts cron
mkdir -p /tmp/backup-repo-smoke
docker run -d --name backup-smoke \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=smoketest \
-v /tmp/backup-repo-smoke:/backups \
dragonsstash-backup:test
sleep 2
docker logs backup-smoke
docker exec backup-smoke restic snapshots
docker rm -f backup-smoke
rm -rf /tmp/backup-repo-smoke
Expected: docker logs shows no errors (restic init ran silently); restic snapshots prints an empty snapshot list (repo exists, header row only, no error).
- Step 8: Commit
git add backup/Dockerfile backup/entrypoint.sh backup/backup.sh backup/crontab
git commit -m "Add backup service image (Dockerfile, entrypoint, crontab)"
Task 2: backup.sh script
Files:
- Modify:
backup/backup.sh(replace Task 1's stub with the real script)
Interfaces:
-
Consumes: the image built in Task 1 (
dragonsstash-backup:test), rebuilt after this change. -
Produces:
/backup.sh, invoked by cron in Task 1'scrontaband manually in Task 6's verification. Reads env varsPOSTGRES_USER,PGPASSWORD,POSTGRES_DB,RESTIC_REPOSITORY,RESTIC_PASSWORD,KUMA_PUSH_URL. Assumes network hostnamedragonsstash-db:5432for Postgres and mounts/data/tdlib-worker,/data/tdlib-bot(read-only) for TDLib state. -
Step 1: Replace
backup/backup.shwith the real script
#!/bin/bash
set -euo pipefail
report_failure() {
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=down" \
--data-urlencode "msg=$BASH_COMMAND failed" || true
}
trap report_failure ERR
DUMP_FILE=/tmp/dragonsstash.dump
TAR_FILE=/tmp/tdlib.tar.gz
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_FILE"
tar czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot
restic backup "$DUMP_FILE" "$TAR_FILE"
restic forget --keep-daily 14 --prune
rm -f "$DUMP_FILE" "$TAR_FILE"
curl -fsS "$KUMA_PUSH_URL" --get \
--data-urlencode "status=up" \
--data-urlencode "msg=OK"
- Step 2: Rebuild the image
Run: docker build -t dragonsstash-backup:test -f backup/Dockerfile .
Expected: build succeeds.
- Step 3: Stand up a scratch Postgres aliased as
dragonsstash-db
docker network create backup-test-net 2>/dev/null || true
docker run -d --name test-pg --network backup-test-net --network-alias dragonsstash-db \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e POSTGRES_DB=dragonsstash \
postgres:16-alpine
sleep 5
docker exec test-pg pg_isready -U dragons -d dragonsstash
Expected: accepting connections.
- Step 4: Stand up a mock Kuma push endpoint
mkdir -p /tmp/mock-kuma-root && touch /tmp/mock-kuma-root/push
docker run -d --name mock-kuma --network backup-test-net \
-v /tmp/mock-kuma-root:/srv -w /srv python:3-alpine \
python3 -m http.server 8000
sleep 1
- Step 5: Prepare fake TDLib state and a local restic repo dir
mkdir -p /tmp/backup-test/tdlib-worker /tmp/backup-test/tdlib-bot /tmp/backup-test/repo
echo "fake-session" > /tmp/backup-test/tdlib-worker/state.bin
echo "fake-session" > /tmp/backup-test/tdlib-bot/state.bin
- Step 6: Initialize the test restic repo and run
backup.sh(success path)
docker run --rm --network backup-test-net \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-v /tmp/backup-test/repo:/backups \
--entrypoint restic dragonsstash-backup:test init
docker run --rm --network backup-test-net \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e PGPASSWORD=stash -e POSTGRES_DB=dragonsstash \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
-v /tmp/backup-test/repo:/backups \
--entrypoint /backup.sh dragonsstash-backup:test
echo "exit code: $?"
Expected: exit code 0, restic prints a line like snapshot xxxxxxxx saved, no error output.
- Step 7: Verify the snapshot landed and contains both files
docker run --rm -v /tmp/backup-test/repo:/backups \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
--entrypoint restic dragonsstash-backup:test snapshots
docker run --rm -v /tmp/backup-test/repo:/backups \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
--entrypoint restic dragonsstash-backup:test ls latest
Expected: snapshots shows exactly one entry; ls latest lists /tmp/dragonsstash.dump and /tmp/tdlib.tar.gz.
- Step 8: Verify the failure path reports to Kuma
docker run --rm --network backup-test-net \
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=wrongpass -e PGPASSWORD=wrongpass -e POSTGRES_DB=dragonsstash \
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
-v /tmp/backup-test/repo:/backups \
--entrypoint /backup.sh dragonsstash-backup:test
echo "exit code: $?"
docker logs mock-kuma | tail -5
Expected: exit code nonzero (pg_dump auth failure trips set -e); docker logs mock-kuma shows a GET request line containing status=down.
- Step 9: Clean up test resources
docker rm -f test-pg mock-kuma
docker network rm backup-test-net
rm -rf /tmp/backup-test /tmp/mock-kuma-root
- Step 10: Commit
git add backup/backup.sh
git commit -m "Implement backup.sh: pg_dump + tdlib tar + restic backup/forget + Kuma reporting"
Task 3: Wire the backup service into the repo's docker-compose.yml
Files:
- Modify:
docker-compose.yml - Modify:
.env.example
Interfaces:
-
Consumes:
backup/Dockerfile(Task 1),backup/backup.sh(Task 2). -
Produces: a
backupcompose service buildable viadocker compose build backup, and anas_backupsnamed volume other tasks (5) will mirror into the production compose file. -
Step 1: Add the
backupservice todocker-compose.yml
Insert after the existing bot service (before db):
backup:
build:
context: .
dockerfile: backup/Dockerfile
pull_policy: never
environment:
- POSTGRES_USER=${POSTGRES_USER:-dragons}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
- RESTIC_REPOSITORY=/backups/restic-repo
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
- TZ=${TZ:-Etc/UTC}
volumes:
- tdlib_state:/data/tdlib-worker:ro
- tdlib_bot_state:/data/tdlib-bot:ro
- nas_backups:/backups
depends_on:
db:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
networks:
- backend
- Step 2: Add the
nas_backupsvolume to thevolumes:block
nas_backups:
driver_opts:
type: nfs
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
device: ":${NAS_EXPORT_PATH}"
- Step 3: Document the new env vars in
.env.example
Append:
# Backup (NAS via NFS + restic)
NAS_HOST="" # Synology NAS IP or hostname reachable from this host
NAS_EXPORT_PATH="" # NFS export path, e.g. /volume1/dragonsstash-backups
RESTIC_PASSWORD="" # generate with: openssl rand -base64 32
KUMA_PUSH_URL="" # Uptime Kuma Push monitor URL (create the monitor first)
TZ="Etc/UTC"
- Step 4: Validate the compose file parses
Run (with dummy values so the :? guards don't fail parsing — AUTH_SECRET is required by the existing app service, not by this change, but config validates the whole file):
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
docker compose -f docker-compose.yml config --quiet
Expected: no output, exit code 0 (a syntax/interpolation error would print to stderr and exit nonzero).
- Step 5: Validate the service actually builds through Compose
Run:
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
docker compose -f docker-compose.yml build backup
Expected: build succeeds (reuses Task 1's image layers).
- Step 6: Commit
git add docker-compose.yml .env.example
git commit -m "Add backup service and nas_backups volume to docker-compose.yml"
Task 4: CI — build and push the backup image
Files:
- Modify:
.drone.yml
Interfaces:
-
Consumes:
backup/Dockerfile(Task 1). -
Produces:
git.samagsteribbe.nl/admin/dragonsstash-backup:latest(and:<short-sha>), pushed on every push tomain. Task 5's production compose file references this image tag. -
Step 1: Add a
build-backupstep, mirroringbuild-worker/build-bot
Insert after the existing build-bot step in .drone.yml:
- name: build-backup
image: plugins/docker
depends_on: [clone]
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash-backup
registry: git.samagsteribbe.nl
dockerfile: backup/Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- Step 2: Add
build-backupto thedeploystep'sdepends_on
Change:
- name: deploy
image: alpine
depends_on: [build-app, build-worker, build-bot]
to:
- name: deploy
image: alpine
depends_on: [build-app, build-worker, build-bot, build-backup]
- Step 3: Validate YAML syntax
Run: python3 -c "import yaml; yaml.safe_load(open('.drone.yml')); print('OK')"
Expected: OK printed, no exception.
- Step 4: Commit
git add .drone.yml
git commit -m "Add build-backup CI step, include it in deploy dependencies"
Task 5: Wire production (/opt/stacks/DragonsStash) — requires NAS + Kuma details from the user
Do not start this task until the user has supplied NAS_HOST and NAS_EXPORT_PATH for the Synology NFS share, and has created an Uptime Kuma Push monitor (name it dragonsstash-backup, ~26h expected heartbeat interval) and shared its push URL. If any of these are missing, stop and ask — do not substitute placeholder values here, since this file drives the real deployment.
Files:
- Modify:
/opt/stacks/DragonsStash/docker-compose.yml - Modify:
/opt/stacks/DragonsStash/.env
Interfaces:
-
Consumes:
git.samagsteribbe.nl/admin/dragonsstash-backup:latest(published by Task 4's CI step once merged/pushed), the realNAS_HOST/NAS_EXPORT_PATH/KUMA_PUSH_URLvalues gathered above. -
Produces: a running
dragonsstash-backupcontainer on the production host, verified in Task 6. -
Step 1: Generate
RESTIC_PASSWORDand add all four new vars to/opt/stacks/DragonsStash/.env
cd /opt/stacks/DragonsStash
printf '\n# Backup (NAS via NFS + restic)\nNAS_HOST="<value from user>"\nNAS_EXPORT_PATH="<value from user>"\nRESTIC_PASSWORD="%s"\nKUMA_PUSH_URL="<value from user>"\nTZ="Etc/UTC"\n' "$(openssl rand -base64 32)" >> .env
Replace the two <value from user> placeholders with the real NAS details and Kuma push URL before saving — this step cannot be completed with the literal placeholder text left in place.
- Step 2: Add the
backupservice to/opt/stacks/DragonsStash/docker-compose.yml
Insert after the existing bot service (before db):
backup:
image: git.samagsteribbe.nl/admin/dragonsstash-backup:latest
container_name: dragonsstash-backup
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER:-dragons}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
- RESTIC_REPOSITORY=/backups/restic-repo
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
- TZ=${TZ:-Etc/UTC}
volumes:
- tdlib_state:/data/tdlib-worker:ro
- tdlib_bot_state:/data/tdlib-bot:ro
- nas_backups:/backups
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
memory: 256M
networks:
- internal
- Step 3: Add the
nas_backupsvolume
nas_backups:
driver_opts:
type: nfs
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
device: ":${NAS_EXPORT_PATH}"
- Step 4: Validate the production compose file parses with the real
.env
Run: cd /opt/stacks/DragonsStash && docker compose config --quiet
Expected: no output, exit code 0.
- Step 5: Commit is not applicable here —
/opt/stacks/DragonsStashis a deployed copy, not the git repo (confirm withgit -C /opt/stacks/DragonsStash status— expect "not a git repository"). Skip committing; Task 6 deploys these file changes directly.
Task 6: Deploy and verify
Files: none (operational task)
Interfaces:
-
Consumes: everything from Tasks 1–5.
-
Produces: a running, verified backup on the real NAS, and one completed restore drill.
-
Step 1: Confirm with the user before pushing/deploying
Pushing to main triggers Drone CI to build all four images and deploy to the production host via SSH (docker compose pull && docker compose up -d). Confirm the user wants this to happen now before proceeding — this affects the live stack.
- Step 2: Push to
main
cd /home/sam/Documents/DragonsStash
git push origin main
Expected: Drone pipeline runs build-app, build-worker, build-bot, build-backup, then deploy, all green. Check via the Drone UI or drone build info admin/DragonsStash <build-number> if the drone CLI is configured.
- Step 3: Confirm the container is up on the production host
ssh sam@192.168.68.68 "docker ps --filter name=dragonsstash-backup --format '{{.Names}}\t{{.Status}}'"
Expected: dragonsstash-backup Up ....
- Step 4: Trigger one manual backup run and confirm a snapshot lands on the NAS
ssh sam@192.168.68.68 "docker exec dragonsstash-backup /backup.sh"
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic snapshots"
Expected: backup.sh exits 0; restic snapshots lists exactly one entry.
- Step 5: Confirm the Uptime Kuma push monitor shows green
Open the Uptime Kuma dashboard and check the dragonsstash-backup monitor's status is up with a recent heartbeat.
- Step 6: Restore drill — prove the backup is actually restorable
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic restore latest --target /tmp/restore-drill"
ssh sam@192.168.68.68 "docker exec dragonsstash-backup ls -la /tmp/restore-drill/tmp"
Expected: /tmp/restore-drill/tmp/dragonsstash.dump and /tmp/restore-drill/tmp/tdlib.tar.gz both present with nonzero size. Then, on a scratch Postgres (not the live dragonsstash-db), confirm the dump restores cleanly:
ssh sam@192.168.68.68 "docker run -d --rm --name restore-drill-pg --network dragonsstash_internal \
-e POSTGRES_USER=drill -e POSTGRES_PASSWORD=drill -e POSTGRES_DB=drill postgres:16-alpine"
ssh sam@192.168.68.68 "docker cp dragonsstash-backup:/tmp/restore-drill/tmp/dragonsstash.dump /tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker cp /tmp/dragonsstash.dump restore-drill-pg:/tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg pg_restore -U drill -d drill --clean --if-exists /tmp/dragonsstash.dump"
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg psql -U drill -d drill -c '\\dt' | head -20"
ssh sam@192.168.68.68 "docker rm -f restore-drill-pg"
Expected: pg_restore completes without fatal errors; \dt lists the app's tables (e.g. Package, User, TelegramLink).
- Step 7: Clean up drill artifacts
ssh sam@192.168.68.68 "docker exec dragonsstash-backup rm -rf /tmp/restore-drill"
ssh sam@192.168.68.68 "rm -f /tmp/dragonsstash.dump"