mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
444 lines
18 KiB
Markdown
444 lines
18 KiB
Markdown
# Production backup and recovery
|
|
|
|
Dragon's Stash backs up its PostgreSQL database, durable STL uploads, and the
|
|
worker and bot Telegram (TDLib) session volumes to an encrypted Restic
|
|
repository on a Synology NFS share. Docker volumes are local runtime storage;
|
|
they are not, by themselves, backups.
|
|
|
|
The backup job runs as `root` because it reads the Restic password file and
|
|
mounts host paths into the backup container. Keep the Synology share on the
|
|
private network. Do **not** expose NFS to the Internet.
|
|
|
|
## Protected data and limits
|
|
|
|
Each snapshot contains:
|
|
|
|
- a custom-format PostgreSQL dump;
|
|
- the `manual_uploads` volume at `/data/uploads` (including newly completed
|
|
manual STL uploads);
|
|
- the worker TDLib session volume; and
|
|
- the bot TDLib session volume.
|
|
|
|
New manual-upload STL files are retained in `manual_uploads` and can therefore
|
|
be included in future backups. STL files that an older worker run already
|
|
deleted cannot be recovered by this backup feature. Their legacy database rows
|
|
are reported as warnings during restore validation; only files marked as
|
|
retained must be present in a restored snapshot.
|
|
|
|
## 1. Configure the Synology NFS share
|
|
|
|
On the Synology DSM host:
|
|
|
|
1. In **Control Panel > Shared Folder**, create a shared folder named
|
|
`dragonsstash-backups`.
|
|
2. In **Control Panel > File Services > NFS**, enable NFS.
|
|
3. Edit the new shared folder and add an **NFS Permissions** entry for the
|
|
Docker host's fixed private-network IP address. Grant read/write access.
|
|
Use the least permissive squash and authentication settings that work for
|
|
the root-run backup job, and do not use a broad subnet or public address.
|
|
4. Record the NFS export path shown by DSM (for example,
|
|
`/volume1/dragonsstash-backups`).
|
|
|
|
On the Docker host, install the NFS client package for its distribution, create
|
|
the mountpoint, and mount the export. Replace `NAS_IP` and the export path with
|
|
the values for the Synology:
|
|
|
|
```bash
|
|
sudo mkdir -p /mnt/dragonsstash-backups
|
|
sudo mount -t nfs -o nfsvers=4 NAS_IP:/volume1/dragonsstash-backups /mnt/dragonsstash-backups
|
|
```
|
|
|
|
For a persistent mount, add an `/etc/fstab` entry using the DSM export path:
|
|
|
|
```fstab
|
|
NAS_IP:/volume1/dragonsstash-backups /mnt/dragonsstash-backups nfs nfsvers=4,_netdev,nofail,x-systemd.automount 0 0
|
|
```
|
|
|
|
Then verify that the host sees an actual writable mount rather than an empty
|
|
local directory:
|
|
|
|
```bash
|
|
mountpoint /mnt/dragonsstash-backups
|
|
touch /mnt/dragonsstash-backups/.write-test
|
|
rm /mnt/dragonsstash-backups/.write-test
|
|
```
|
|
|
|
The scheduled backup refuses to run if `BACKUP_MOUNT_PATH` is not a mountpoint
|
|
or is not writable.
|
|
|
|
## 2. Create secrets and host configuration
|
|
|
|
Create a root-readable Restic password file. Generate and store a strong,
|
|
unique repository password by your approved secret-management process; never
|
|
commit it, place it in the Compose file, or paste it into shell history.
|
|
|
|
```bash
|
|
sudo install -d -m 0700 -o root -g root /etc/dragons-stash
|
|
sudo install -m 0600 -o root -g root /dev/null /etc/dragons-stash/restic-password
|
|
sudoedit /etc/dragons-stash/restic-password
|
|
```
|
|
|
|
Create a local staging directory. It must be on host-local storage, not the NFS
|
|
share, and must have enough room for a PostgreSQL dump and restore staging
|
|
data.
|
|
|
|
```bash
|
|
sudo install -d -m 0700 -o root -g root /var/lib/dragons-stash/backup-staging
|
|
```
|
|
|
|
Create the systemd environment file at `/etc/dragons-stash/backup.env` and keep
|
|
it root-only. It supplies both the host wrapper and the profile-gated backup
|
|
container:
|
|
|
|
```bash
|
|
sudo install -m 0600 -o root -g root /dev/null /etc/dragons-stash/backup.env
|
|
sudoedit /etc/dragons-stash/backup.env
|
|
```
|
|
|
|
Set these production values (replace the example paths and retention period as
|
|
needed):
|
|
|
|
```dotenv
|
|
BACKUP_MOUNT_PATH=/mnt/dragonsstash-backups
|
|
BACKUP_STAGING_PATH=/var/lib/dragons-stash/backup-staging
|
|
BACKUP_RESTIC_PASSWORD_FILE=/etc/dragons-stash/restic-password
|
|
BACKUP_REPOSITORY=/backup/restic
|
|
BACKUP_RETENTION_DAYS=30
|
|
BACKUP_APP_VERSION=unknown
|
|
```
|
|
|
|
`BACKUP_REPOSITORY` is evaluated inside the backup container, where the NFS
|
|
share is mounted at `/backup`; `/backup/restic` keeps the Restic repository in
|
|
the Synology share. `BACKUP_APP_VERSION` is optional metadata. The production
|
|
Compose environment must also retain its existing database and application
|
|
secrets; do not add any secrets to Git.
|
|
|
|
## 3. Initialize the Restic repository once
|
|
|
|
Before enabling the scheduled job, initialize a new repository explicitly:
|
|
|
|
```bash
|
|
docker compose --profile backup run --rm backup init
|
|
```
|
|
|
|
The backup container preflight refuses to create a repository implicitly. If a
|
|
scheduled backup reports that the repository is unavailable or uninitialized,
|
|
verify the NFS mount, `BACKUP_REPOSITORY`, and password file before running the
|
|
explicit initialization command for an intended new repository.
|
|
|
|
## 4. Install the nightly systemd job
|
|
|
|
The supplied unit assumes the production Compose checkout is
|
|
`/opt/stacks/DragonsStash`. If your deployment lives elsewhere, update the
|
|
installed service's `WorkingDirectory` and `ExecStart` to the corresponding
|
|
absolute paths before enabling it.
|
|
|
|
Install and start the units:
|
|
|
|
```bash
|
|
sudo install -m 0644 deploy/systemd/dragons-stash-backup.service /etc/systemd/system/
|
|
sudo install -m 0644 deploy/systemd/dragons-stash-backup.timer /etc/systemd/system/
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now dragons-stash-backup.timer
|
|
sudo systemctl start dragons-stash-backup.service
|
|
sudo journalctl -u dragons-stash-backup.service -n 100 --no-pager
|
|
```
|
|
|
|
The timer runs nightly at 03:00 with up to 15 minutes of randomized delay and
|
|
catches up after downtime. The first run can take a long time because it uploads
|
|
all existing STL and session data. Later Restic snapshots deduplicate unchanged
|
|
data.
|
|
|
|
## 5. Monitor and maintain backups
|
|
|
|
Inspect the next scheduled run and the last service result:
|
|
|
|
```bash
|
|
systemctl list-timers dragons-stash-backup.timer
|
|
systemctl status dragons-stash-backup.timer
|
|
systemctl status dragons-stash-backup.service
|
|
sudo journalctl -u dragons-stash-backup.service -n 100 --no-pager
|
|
sudo systemctl --failed
|
|
```
|
|
|
|
List the snapshots through the configured backup container:
|
|
|
|
```bash
|
|
./scripts/backup/restore.sh list
|
|
```
|
|
|
|
Check a selected repository snapshot before restoring it:
|
|
|
|
```bash
|
|
./scripts/backup/restore.sh verify SNAPSHOT_ID
|
|
```
|
|
|
|
`verify` runs `restic snapshots SNAPSHOT_ID` and `restic check`. Routine backup
|
|
runs apply retention with `restic forget --keep-daily "$BACKUP_RETENTION_DAYS"
|
|
--prune`; with the example configuration, this retains 30 daily snapshots.
|
|
Choose `BACKUP_RETENTION_DAYS` based on storage capacity and the recovery
|
|
window you need. Watch Synology capacity and investigate any failed timer or
|
|
service promptly.
|
|
|
|
At least monthly, perform a full repository read check:
|
|
|
|
```bash
|
|
docker compose --profile backup run --rm backup check --read-data
|
|
```
|
|
|
|
### Monthly disposable recovery rehearsal
|
|
|
|
Perform the following procedure at least monthly. It restores one snapshot into
|
|
a unique, disposable Compose project, so the database and all Compose volumes
|
|
are isolated from production. **Never use the production Compose project name,
|
|
production volume names, or `restore-live` for this rehearsal.** In particular,
|
|
do not run `docker compose down -v` without the explicit rehearsal
|
|
`--project-name` shown below.
|
|
|
|
Run these commands from the production Compose checkout as an operator allowed
|
|
to read `/etc/dragons-stash/backup.env`. They use the configuration required
|
|
for a separate application stack; do not expose its published port beyond the
|
|
host. The worker and bot are deliberately replaced with inert processes, so
|
|
this validates their restored images and volumes without executing Telegram
|
|
clients or using production Telegram credentials. First select a snapshot and
|
|
set the expected values for a known retained STL that was recorded when the
|
|
backup was made. `EXPECTED_FILE_PATH` must be the database value under
|
|
`/data/uploads`, and `EXPECTED_FILE_SIZE` is bytes.
|
|
|
|
```bash
|
|
set -Eeuo pipefail
|
|
set -a
|
|
. /etc/dragons-stash/backup.env
|
|
set +a
|
|
|
|
./scripts/backup/restore.sh list
|
|
SNAPSHOT_ID=SNAPSHOT_ID_FROM_LIST
|
|
REHEARSAL_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
|
REHEARSAL_PROJECT="dragonsstash-rehearsal-$REHEARSAL_ID"
|
|
REHEARSAL_DIR="$BACKUP_STAGING_PATH/monthly-rehearsal-$REHEARSAL_ID"
|
|
REHEARSAL_ENV="$REHEARSAL_DIR/compose.env"
|
|
REHEARSAL_OVERRIDE="$REHEARSAL_DIR/compose.rehearsal.override.yml"
|
|
REHEARSAL_APP_PORT=13000 # Choose an unused host-local port.
|
|
|
|
EXPECTED_UPLOAD_ID=RECORDED_UPLOAD_ID
|
|
EXPECTED_UPLOAD_STATUS=COMPLETED
|
|
EXPECTED_FILE_NAME=RECORDED_FILENAME.stl
|
|
EXPECTED_FILE_PATH=/data/uploads/RECORDED_RELATIVE_PATH.stl
|
|
EXPECTED_FILE_SIZE=RECORDED_SIZE_IN_BYTES
|
|
EXPECTED_SHA256=RECORDED_SHA256
|
|
|
|
./scripts/backup/restore.sh verify "$SNAPSHOT_ID"
|
|
./scripts/backup/restore.sh restore-to-staging "$SNAPSHOT_ID" "$REHEARSAL_DIR"
|
|
RESTORE_ROOT="$(printf '%s\n' "$REHEARSAL_DIR"/staging/backup-*)"
|
|
sha256sum --check "$RESTORE_ROOT/manifest/database.dump.sha256"
|
|
|
|
umask 077
|
|
cp .env "$REHEARSAL_ENV"
|
|
printf '\nAPP_PORT=%s\nNEXT_PUBLIC_APP_URL=http://localhost:%s\n' \
|
|
"$REHEARSAL_APP_PORT" "$REHEARSAL_APP_PORT" >> "$REHEARSAL_ENV"
|
|
|
|
cat > "$REHEARSAL_OVERRIDE" <<'EOF'
|
|
services:
|
|
worker:
|
|
environment:
|
|
TELEGRAM_API_ID: ""
|
|
TELEGRAM_API_HASH: ""
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command: ["exec sleep infinity"]
|
|
bot:
|
|
environment:
|
|
BOT_TOKEN: ""
|
|
TELEGRAM_API_ID: ""
|
|
TELEGRAM_API_HASH: ""
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command: ["exec sleep infinity"]
|
|
EOF
|
|
```
|
|
|
|
Confirm that `RESTORE_ROOT` names exactly one `backup-*` directory before
|
|
continuing. The staging restore has already verified the custom PostgreSQL dump
|
|
and restored `data/uploads`, `data/tdlib-worker`, and `data/tdlib-bot`.
|
|
|
|
Create the isolated project and volumes, start only its database, then import
|
|
the dump. `compose_rehearsal()` always applies the disposable override created
|
|
above: worker and bot retain their restored images and volumes but have their
|
|
Telegram credential variables blanked and run only `sleep infinity`, never
|
|
Telegram clients. The `create` command makes the project-scoped application
|
|
volumes without starting app, worker, or bot.
|
|
|
|
```bash
|
|
compose_rehearsal() {
|
|
docker compose --project-name "$REHEARSAL_PROJECT" --env-file "$REHEARSAL_ENV" \
|
|
-f docker-compose.yml -f "$REHEARSAL_OVERRIDE" "$@"
|
|
}
|
|
rehearsal_volume() {
|
|
docker volume ls \
|
|
--filter "label=com.docker.compose.project=$REHEARSAL_PROJECT" \
|
|
--filter "label=com.docker.compose.volume=$1" \
|
|
--format '{{.Name}}'
|
|
}
|
|
|
|
compose_rehearsal --profile full create app worker bot
|
|
compose_rehearsal --profile full up -d db
|
|
compose_rehearsal exec -T db dropdb --if-exists --force \
|
|
--username "${POSTGRES_USER:-dragons}" "${POSTGRES_DB:-dragonsstash}"
|
|
compose_rehearsal exec -T db createdb --username "${POSTGRES_USER:-dragons}" \
|
|
"${POSTGRES_DB:-dragonsstash}"
|
|
compose_rehearsal exec -T db pg_restore --no-owner --exit-on-error \
|
|
--username "${POSTGRES_USER:-dragons}" --dbname "${POSTGRES_DB:-dragonsstash}" \
|
|
< "$RESTORE_ROOT/database.dump"
|
|
```
|
|
|
|
Copy each restored file tree into its matching **rehearsal** volume. Each
|
|
target is new and empty; the function rejects an ambiguous volume lookup.
|
|
|
|
```bash
|
|
restore_rehearsal_volume() {
|
|
local logical_name="$1"
|
|
local source_dir="$2"
|
|
local volume_name
|
|
volume_name="$(rehearsal_volume "$logical_name")"
|
|
test -n "$volume_name"
|
|
test "$(printf '%s\n' "$volume_name" | wc -l)" -eq 1
|
|
compose_rehearsal --profile backup run --rm --no-deps \
|
|
--entrypoint bash \
|
|
-v "$source_dir:/restore-source:ro" \
|
|
-v "$volume_name:/restore-target" \
|
|
backup -ceu 'cp -a /restore-source/. /restore-target/'
|
|
}
|
|
|
|
restore_rehearsal_volume manual_uploads "$REHEARSAL_DIR/data/uploads"
|
|
restore_rehearsal_volume tdlib_state "$REHEARSAL_DIR/data/tdlib-worker"
|
|
restore_rehearsal_volume tdlib_bot_state "$REHEARSAL_DIR/data/tdlib-bot"
|
|
```
|
|
|
|
Start the disposable app, worker, and bot containers. The app and database
|
|
perform their normal health and restored-data checks; the worker and bot remain
|
|
inert `sleep infinity` processes, so this does not execute Telegram clients or
|
|
send messages. Check the health endpoint, then retain the `ps` and log output
|
|
as rehearsal evidence.
|
|
|
|
```bash
|
|
compose_rehearsal --profile full up -d app worker bot
|
|
curl --fail --silent --show-error \
|
|
"http://localhost:$REHEARSAL_APP_PORT/api/health"
|
|
compose_rehearsal --profile full ps
|
|
compose_rehearsal --profile full logs --tail=100 app worker bot
|
|
```
|
|
|
|
Validate that every retained database file reference has a restored file. Rows
|
|
whose `retainedAt` is `NULL` are legacy references and are warnings, not
|
|
failures. Then compare the recorded STL checksum and metadata with the
|
|
disposable database and volume; both commands must succeed.
|
|
|
|
```bash
|
|
compose_rehearsal exec -T db psql --no-psqlrc --tuples-only --no-align --quiet \
|
|
--field-separator=$'\t' --username "${POSTGRES_USER:-dragons}" \
|
|
--dbname "${POSTGRES_DB:-dragonsstash}" \
|
|
--command "SELECT CASE WHEN \"retainedAt\" IS NULL THEN 'legacy' ELSE 'retained' END, \"filePath\" FROM \"manual_upload_files\" ORDER BY 2" |
|
|
compose_rehearsal --profile backup run --rm --no-deps -T --entrypoint bash backup -ceu '
|
|
missing=0
|
|
while IFS="$(printf "\\t")" read -r retention file_path; do
|
|
if [[ "$retention" == legacy ]]; then
|
|
printf "Warning: legacy reference is not required: %s\\n" "$file_path" >&2
|
|
elif [[ "$retention" != retained || "$file_path" != /data/uploads/* || ! -f "/data/uploads/${file_path#/data/uploads/}" ]]; then
|
|
printf "Missing or invalid retained upload: %s\\n" "$file_path" >&2
|
|
missing=1
|
|
fi
|
|
done
|
|
exit "$missing"
|
|
'
|
|
|
|
actual_sha256="$(compose_rehearsal --profile backup run --rm --no-deps \
|
|
--entrypoint sha256sum backup "$EXPECTED_FILE_PATH" | awk '{print $1}')"
|
|
test "$actual_sha256" = "$EXPECTED_SHA256"
|
|
|
|
metadata_rows="$(compose_rehearsal exec -T db psql --no-psqlrc --tuples-only \
|
|
--no-align --quiet --username "${POSTGRES_USER:-dragons}" \
|
|
--dbname "${POSTGRES_DB:-dragonsstash}" \
|
|
--set="upload_id=$EXPECTED_UPLOAD_ID" --set="upload_status=$EXPECTED_UPLOAD_STATUS" \
|
|
--set="file_name=$EXPECTED_FILE_NAME" --set="file_path=$EXPECTED_FILE_PATH" \
|
|
--set="file_size=$EXPECTED_FILE_SIZE" --command "SELECT count(*) FROM \"manual_uploads\" u JOIN \"manual_upload_files\" f ON f.\"uploadId\" = u.id WHERE u.id = :'upload_id' AND u.status::text = :'upload_status' AND f.\"fileName\" = :'file_name' AND f.\"filePath\" = :'file_path' AND f.\"fileSize\" = :'file_size'::bigint AND f.\"retainedAt\" IS NOT NULL;")"
|
|
test "$metadata_rows" = 1
|
|
```
|
|
|
|
After recording the evidence, destroy only the explicitly named disposable
|
|
project and its project-labeled volumes. Do not remove `REHEARSAL_DIR` until
|
|
the evidence is recorded; then remove only that generated child directory by
|
|
your approved host cleanup procedure. Production services, volumes, and backup
|
|
snapshots must remain untouched.
|
|
|
|
```bash
|
|
compose_rehearsal --profile full down --volumes --remove-orphans
|
|
docker volume ls --filter "label=com.docker.compose.project=$REHEARSAL_PROJECT"
|
|
test -z "$(docker volume ls --quiet --filter "label=com.docker.compose.project=$REHEARSAL_PROJECT")"
|
|
```
|
|
|
|
Record this evidence for every rehearsal; do not include secrets, database
|
|
dumps, Telegram session contents, or private NAS details.
|
|
|
|
```text
|
|
Disposable restore rehearsal
|
|
Date/time (UTC):
|
|
Operator:
|
|
Snapshot ID:
|
|
Snapshot backup date:
|
|
Disposable Compose project:
|
|
Health endpoint result (HTTP/body):
|
|
docker compose ps result:
|
|
app/worker/bot log review result:
|
|
Retained-file reference validation result:
|
|
Known retained STL upload ID/path:
|
|
Expected SHA-256 / restored SHA-256:
|
|
Expected metadata (status, filename, size, retainedAt) / restored result:
|
|
Database dump manifest checksum result:
|
|
Cleanup result (project and rehearsal volumes absent):
|
|
Notes/caveats:
|
|
```
|
|
|
|
This document describes the procedure only; it has not been run by this
|
|
documentation update.
|
|
|
|
## 6. Restore modes
|
|
|
|
Run restore commands from the production Compose checkout after loading the
|
|
same backup environment used by systemd (for example, as root with
|
|
`/etc/dragons-stash/backup.env` exported). All restore staging directories must
|
|
be children of `BACKUP_STAGING_PATH`.
|
|
|
|
| Mode | Command | Effect |
|
|
| --- | --- | --- |
|
|
| List snapshots | `./scripts/backup/restore.sh list` | Lists available Restic snapshots. Does not stop services or change volumes. |
|
|
| Verify a snapshot | `./scripts/backup/restore.sh verify SNAPSHOT_ID` | Confirms the snapshot exists and runs a Restic repository check. Does not change live data. |
|
|
| Restore to staging | `./scripts/backup/restore.sh restore-to-staging SNAPSHOT_ID STAGING_DIR` | Restores and validates a snapshot in a new or empty child directory of `BACKUP_STAGING_PATH`. Does not stop services or change volumes. |
|
|
| Replace live data | `./scripts/backup/restore.sh restore-live SNAPSHOT_ID --confirm-replace-live-data` | Stops application services and replaces the PostgreSQL database plus all protected volumes after validation. |
|
|
|
|
`restore-live` is destructive. It requires the exact
|
|
`--confirm-replace-live-data` flag and should be used only after a successful
|
|
staging restore has been inspected. It creates a safety database dump and
|
|
archives of the current protected volumes in local staging before replacement.
|
|
If a live restore fails, it leaves the application services stopped, retains the
|
|
safety artifacts and staging directory, and attempts rollback after replacement
|
|
has begun. Review the reported paths and service health before manually
|
|
starting services.
|
|
|
|
After a successful live restore, confirm the services are running and inspect
|
|
their recent logs before declaring recovery complete:
|
|
|
|
```bash
|
|
docker compose ps
|
|
docker compose logs --tail=100 app worker bot
|
|
```
|
|
|
|
For a non-destructive recovery rehearsal, choose a snapshot from `list` and
|
|
restore it to a fresh staging directory, for example:
|
|
|
|
```bash
|
|
./scripts/backup/restore.sh restore-to-staging SNAPSHOT_ID /var/lib/dragons-stash/backup-staging/rehearsal-SNAPSHOT_ID
|
|
```
|
|
|
|
The restored tree must contain a non-empty PostgreSQL dump, its manifest,
|
|
uploads, worker TDLib state, and bot TDLib state. Live restore additionally
|
|
checks every retained manual-upload file reference against the staged uploads
|
|
before replacing any live volume.
|