feat(ops): schedule database and uploads backups, and document the restore (#147)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m52s

The only copy of every customer, order and one-of-a-kind item was the live Postgres data directory, plus whatever the deploy checklist's manual pg_dump happened to have caught. That dump is good and stays, but it only runs when someone deploys: a quiet week meant the newest copy of real customer data was a week old, and nothing bounded the gap.

Two services rather than one, because they are different jobs. The database is small, changes constantly and wants a logical dump — daily, gzipped, 7/4/6 daily-weekly-monthly retention. Uploads are large and append-mostly and want an archive — weekly, 56 days, mounted read-only so a backup process cannot damage the thing it is backing up. Forcing both through one tool would serve one of them badly.

The dumper is pinned to postgres-backup-local:16 to match the server. pg_dump refuses to dump a server newer than itself, so a floating tag is a backup that stops working the day Postgres is upgraded — silently, because nothing reads a dump until it is needed. It depends_on the database's existing pg_isready healthcheck, which is the constraint that shaped this: a dumper is a client, and without that the first run after a NAS reboot races Postgres coming up.

Both carry a staleness healthcheck rather than trusting the schedule. A regime that stopped a month ago is indistinguishable from a working one until a restore is attempted, and `find -mmin` is the cheapest thing that tells them apart. It surfaces in Portainer beside the app rather than somewhere separate to remember to look. Windows are the interval plus grace — 26 hours daily, 9 days weekly — so a late run is not a failure, and start_period covers the first cycle when nothing has been written yet.

Verified rather than assumed. Both images were pulled and checked to have a shell and `find`, since a CMD-SHELL healthcheck against an image without one reports unhealthy forever. postgres-backup-local:16 ships pg_dump 16.10 against the postgres:16 server. The healthcheck expression was exercised three ways in the image itself — empty directory, fresh artifact, and one aged three days — and returns unhealthy, healthy, unhealthy. The compose file parses and the #118 drift guard still passes over it.

Three things these deliberately do not cover, written into the compose file and the doc rather than left to be discovered:

They run while the stack runs, so they cannot protect the stack's own teardown. Deleting the Portainer stack deletes them too. That is why the deploy checklist's manual dump stays, and README now says so where the checklist is.

They write to the same volume as the data they protect. That survives a bad migration, a dropped table, a bad deploy and a stack deletion, and not the disk. Getting a copy off /volume1 is a Synology-side job and is what turns this from a convenience into a guarantee.

Daily database against weekly uploads leaves a window where a restore pairs the two from different moments. An orphaned image is harmless; a row without its image is a broken thumbnail on one recent item, usually still on the admin's machine. Neither is data loss, and a synchronised snapshot is not worth the complexity to avoid it.

The restore procedure leads with practising on a throwaway database, because an untested backup is a file of unknown validity and a truncated dump looks exactly like a good one until it matters. It checks row counts and the pgmigrations head — the migration check being the one most easily skipped and most likely to bite, since a dump older than the code restores a schema the app will fail against.

Both open items are listed as unticked in the doc: no off-volume copy exists yet, and no restore has been performed. Until the second is done this documents an untested procedure, and it says so.

Refs #147
This commit is contained in:
2026-08-24 11:23:28 -05:00
parent 949734d1e1
commit ebefcbb76b
3 changed files with 243 additions and 0 deletions
+120
View File
@@ -70,6 +70,10 @@
# PAYPAL_CLIENT_ID Live PayPal credentials. Required because DEMO_MODE is
# PAYPAL_CLIENT_SECRET false below; the app refuses to start without them.
# PAYPAL_WEBHOOK_ID
# BACKUP_PASSPHRASE Optional. Set it and the uploads archives are
# encrypted at rest; leave it empty and they are not.
# See docs/ops/backup-and-restore.md before setting it —
# an archive nobody can decrypt is not a backup.
# USPS_CLIENT_ID Optional. Leave unset to run without address
# USPS_CLIENT_SECRET validation; the app degrades gracefully rather than
# failing, so an empty value is a working configuration.
@@ -209,3 +213,119 @@ services:
options:
max-size: 10m
max-file: "3"
# ---------------------------------------------------------------------------
# Backups (#147)
#
# Two services rather than one, because they are different jobs on different
# cadences. The database is small, changes constantly, and wants a logical
# dump. Uploads are large, append-mostly, and want an archive. Forcing both
# through one tool serves one of them badly.
#
# WHAT THESE DO NOT COVER, and it matters:
#
# They run while the stack runs, so they cannot protect the stack's own
# teardown. Deleting the Portainer stack deletes these containers along with
# everything else. The manual pg_dump in README's deploy steps therefore
# stays exactly where it is — a routine regime and a snapshot taken before a
# risky operation are different jobs, and neither replaces the other.
#
# And they write to the same volume as the data they protect. That survives a
# bad migration, a dropped table, a bad deploy and a stack deletion. It does
# not survive the disk. Getting a copy off /volume1 is a Synology-side job —
# Hyper Backup to another volume, an external disk, or offsite — and it is
# what turns this from a convenience into a guarantee. See
# docs/ops/backup-and-restore.md.
# ---------------------------------------------------------------------------
redefined-designs-db-backup-syn:
# Pinned to 16 to match the server. pg_dump refuses to dump a server newer
# than itself, so a floating tag here is a backup that stops working on the
# day Postgres is upgraded — silently, since nothing reads the dumps until
# they are needed.
image: prodrigestivill/postgres-backup-local:16
container_name: redefined-designs-db-backup-syn
environment:
- TZ=America/Chicago
- POSTGRES_HOST=redefined-designs-db-syn
- POSTGRES_PORT=5432
- POSTGRES_DB=redefined
- POSTGRES_USER=redefined
- POSTGRES_PASSWORD=${DB_PASSWORD}
# Daily at 03:00. Late enough that a deploy is unlikely to be in flight,
# and pg_dump takes a consistent snapshot anyway, so a dump running while
# customers are shopping is fine.
- SCHEDULE=@daily
- BACKUP_KEEP_DAYS=7
- BACKUP_KEEP_WEEKS=4
- BACKUP_KEEP_MONTHS=6
# --clean --if-exists so the dump can be restored over an existing
# database without hand-dropping it first, which is the state a real
# restore happens in.
- POSTGRES_EXTRA_OPTS=--clean --if-exists
volumes:
- /volume1/configs/redefined-designs/backups/postgres:/backups
depends_on:
redefined-designs-db-syn:
# The dumper is a client and needs a server accepting connections. This
# is the constraint that shaped the design: without it the first run
# after a NAS reboot races Postgres coming up.
condition: service_healthy
healthcheck:
# Unhealthy when nothing has been written inside the window. A backup
# regime that stopped a month ago is indistinguishable from a working one
# until a restore is attempted, and this is the cheapest thing that tells
# them apart. It shows in Portainer beside the app rather than somewhere
# separate to remember to look.
#
# 1560 minutes is 26 hours: the daily interval plus two hours of grace, so
# a dump that runs a little late is not reported as a failure.
test: ["CMD-SHELL", "find /backups -name '*.sql.gz' -mmin -1560 | grep -q ."]
interval: 1h
timeout: 30s
retries: 3
# Nothing exists until the first scheduled run, so without this the
# container reports unhealthy for its first day on every fresh deploy.
start_period: 25h
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redefined-designs-uploads-backup-syn:
image: offen/docker-volume-backup:v2
container_name: redefined-designs-uploads-backup-syn
environment:
- TZ=America/Chicago
# Weekly, not daily. Uploads are append-mostly and much larger than the
# database, so a daily full archive would mostly be copies of itself.
- BACKUP_CRON_EXPRESSION=0 4 * * 0
- BACKUP_FILENAME=uploads-%Y-%m-%dT%H-%M-%S.tar.gz
- BACKUP_ARCHIVE=/archive
# Eight weeks. Shorter than the database's tail because each archive is
# far bigger, and an image that was deleted two months ago is not
# something anyone is restoring.
- BACKUP_RETENTION_DAYS=56
- BACKUP_PRUNING_PREFIX=uploads-
# Optional. An empty value means no encryption, which is the default.
- GPG_PASSPHRASE=${BACKUP_PASSPHRASE}
volumes:
# Read-only. A backup process with write access to the thing it is backing
# up is a way to lose both at once.
- /volume1/configs/redefined-designs/uploads:/backup/uploads:ro
- /volume1/configs/redefined-designs/backups/uploads:/archive
healthcheck:
# Nine days: the weekly interval plus two days of grace.
test: ["CMD-SHELL", "find /archive -name 'uploads-*' -mmin -12960 | grep -q ."]
interval: 6h
timeout: 30s
retries: 3
start_period: 8d
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"