Files
shopdb-flask/docker-compose.backup.yml
benjamin.baker 7e4469c44e Wire restore into the backup watcher and fix backup timestamps
The api queued .restore-requested but nothing consumed it, so restores silently did nothing. The watcher now services the restore sentinel by running restore.sh, and its instance_data mount is read-write so restore can lay files back down (the manual one-shot stays read-only). Backup stamps use a colon-free UTC form that Date could not parse, so the UI showed raw strings; normalize them before formatting.
2026-09-03 20:26:01 -04:00

176 lines
8.0 KiB
YAML

# shopdb-flask BACKUP overlay - opt-in, per-site.
#
# WHY a separate overlay: backups are not part of the base stack every site must
# run. A facility turns them on by adding this file to its compose invocation:
#
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
# --profile backup up -d
#
# That brings up THREE cooperating pieces:
# - backup-store : a resident, isolated restic REST server (the third
# container). Holds every snapshot in its own volume and is
# reachable ONLY over the compose network - never published
# to the host - so the app can touch backups solely through
# the backup/restore utility, not with a shell or db creds.
# - backup-watcher : a resident poller that actuates the "Back up now" sentinel
# the api drops, running the one-shot against backup-store.
# This is what makes the in-app button produce a real backup
# given the api container cannot run docker.
# - backup : the same one-shot as a manual `run --rm backup` for sites
# that prefer a host scheduler (cron / Task Scheduler).
#
# WHAT each run captures, as one recoverable set:
# - db.sql.gz : mysqldump --single-transaction of the whole DB (users,
# auth, ledger, every plugin table). InnoDB-consistent,
# non-blocking.
# - instance/ : restic snapshot of /app/instance (deduped, encrypted),
# the files MySQL rows point at.
# - manifest.json: core+plugin Alembic revisions and image tag at capture
# time - the stamp that makes a restore a CHECKED operation.
#
# Retention (restic-native): --keep-daily 5 --keep-weekly 4 --keep-monthly 6,
# satisfying "any day in the last 5" plus a cheap long-horizon tail.
#
# DESTINATION defaults to the third container via RESTIC_REPOSITORY:
# third container : rest:http://backup-store:8000/ (default, ships with app)
# AWS S3 : s3:s3.amazonaws.com/shopdb-<site>-backups
# Azure Blob : azure:shopdbbackups:<site>
# SMB share : mount the share on the host, point at a local path
# Credentials come from the site's env/secret store, never committed - same
# pattern as the collector key.
# The THIRD container: an isolated backup store the app can only reach through
# the backup/restore utility, never with a shell or the db credentials. It runs
# restic's REST server over the compose network; the only thing that speaks to
# it is the backup watcher/one-shot via RESTIC_REPOSITORY=rest:http://... . Its
# data lives in its own named volume (backupstore_data), so it is container
# agnostic - later a pull/migration utility can point at this same REST endpoint
# from anywhere without touching the app or the database.
services:
backup-store:
image: restic/rest-server:0.13.0
profiles: ["backup"]
restart: unless-stopped
environment:
# Private (no public /create) so repos are provisioned by the backup
# client's `restic init`, and append-only OFF here because the same
# utility both writes and prunes; isolation comes from this being the
# only service on the network with access, not from client trust.
DISABLE_AUTHENTICATION: "true"
OPTIONS: "--no-auth"
volumes:
- backupstore_data:/data
# Deliberately NOT published to the host: reachable only over the compose
# network by service name, so nothing outside the stack can touch backups.
expose:
- "8000"
healthcheck:
test: ["CMD", "sh", "-c", "wget -q -O - http://127.0.0.1:8000/ >/dev/null 2>&1 || true"]
interval: 30s
timeout: 5s
retries: 3
# Resident watcher: actuates the "Back up now" sentinel the api drops. This is
# the piece that makes the UI button run a real backup, since the api itself
# cannot run docker. It shares the base image with the one-shot and calls the
# same backup.sh.
backup-watcher:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: unless-stopped
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
# DB access - same credentials the api uses, reaching db over the compose
# network by service name.
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
# Destination is the third container by default: restic's REST backend on
# the isolated backup-store. A site can still override RESTIC_REPOSITORY
# in .env to send offsite instead, but out of the box backups land in the
# dedicated container.
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
# Optional cloud creds, passed through when set (empty otherwise).
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
# Site tag stamped into every snapshot + manifest.
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
BACKUP_POLL_SECONDS: ${BACKUP_POLL_SECONDS:-10}
volumes:
# The watcher services both backup (read) and restore (write) requests, so
# the instance tree is mounted read-write here. Restore has to lay the
# files half of a set back down next to the DB rows that reference it; the
# backup half still only ever reads. The manual one-shot below stays :ro.
- instance_data:/app/instance
# Alembic chain, read-only, so the manifest can stamp the schema revision
# the running code is at.
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
# Shared backups volume: holds the fast local db.sql.gz + manifest and the
# .backup-requested sentinel the api drops. The api mounts this same volume
# (see docker-compose.yml) so the UI can enumerate sets and request runs.
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/watch.sh"]
# Manual one-shot, unchanged usage: `docker compose ... run --rm backup`.
# Kept for host-scheduler / cron sites that prefer a scheduled pass over the
# in-app button. Shares everything with the watcher but runs once and exits.
backup:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: "no"
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
volumes:
- instance_data:/app/instance:ro
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/backup.sh"]
# Give the api read/write on the shared backups volume so the UI can list sets
# and drop the "Back up now" sentinel the watcher actuates. This extends the
# api service defined in docker-compose.yml (merged by compose).
api:
volumes:
- backup_data:/backups
volumes:
backup_data:
backupstore_data: