# 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--backups # Azure Blob : azure:shopdbbackups: # 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: