#!/usr/bin/env bash # Accounted self-host backup: logical database dump + document storage, shipped # to an S3-compatible bucket, optionally under S3 Object Lock (WORM). # # Self-hosted Supabase has no managed backups or PITR, and Swedish bookkeeping # law (BFL 7 kap) requires the ledger and its underlag (receipts, invoices) to # be kept for seven years after the end of the fiscal year. This script is the # minimum that satisfies both: one restorable dump per run, and the documents # bucket alongside it, on storage the operator controls. See # docs/SOVEREIGN.md ("Backup and restore") for the runbook and the cron line. # # Requirements on the host running it: bash, pg_dump and psql (matching the # server's major version), tar, gzip, sha256sum (or shasum), AWS CLI v2 (works # against any S3-compatible endpoint: Safespring, GleSYS, Elastx via # --endpoint-url). # # Environment (required unless marked optional): # BACKUP_DATABASE_URL postgresql://postgres:@:/postgres # Use the Supabase session-mode pooler port or the # db container's port; never a public address. # BACKUP_S3_ENDPOINT e.g. https://s3.sto2.safedc.net # BACKUP_S3_BUCKET bucket name (create it with Object Lock enabled: # Object Lock can only be turned on at creation) # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY credentials for that bucket # BACKUP_S3_REGION optional, default us-east-1 (what most S3-compatible # endpoints expect for SigV4; Elastx requires it) # BACKUP_S3_PREFIX optional, default "accounted" # BACKUP_STORAGE_DIR optional: path of the Supabase storage volume # (STORAGE_BACKEND=file: /volumes/storage). # Omit when storage-api writes straight to S3; then # back that bucket up bucket-to-bucket instead. # BACKUP_DB_CONFIG_VOLUME optional: name of the Supabase `db-config` Docker # volume (e.g. supabase_db-config). It holds the # pgsodium root key; a dump restored without it # cannot decrypt Vault secrets. Requires docker. # BACKUP_OBJECT_LOCK_DAYS optional: when set, every uploaded object gets # COMPLIANCE-mode retention for this many days # (immutable even for the bucket owner). Suggested: # a long value (>= 2600, seven years plus margin) # for the yearly post-bokslut run, a short one for # nightly runs, or unset and rely on bucket default # retention. COMPLIANCE retention cannot be shortened. # BACKUP_WORKDIR optional: scratch directory, default mktemp. # BACKUP_LABEL optional: name fragment, default "nightly". # BACKUP_QUIESCE_CMD optional: a command run BEFORE the dump and # BACKUP_RESUME_CMD AFTER the upload (also on failure), e.g. # "docker compose -f /opt/accounted/docker-compose.yml stop app cron" # and the matching "start". The database dump and # the storage tar are taken one after the other; # an upload landing between them leaves a document # row without its file (or the reverse) in that # set. Stopping the app for the window makes the # set consistent; recommended for the yearly # archive run, optional for nightly runs where the # next night's set covers the gap. Set both or # neither: one without the other is refused # before anything runs. # # Prints a short progress log on stdout; exit status is non-zero on any # failure, which is what a cron wrapper should alert on. set -euo pipefail # Dumps, archives and manifests are the whole ledger: never world-readable, # whatever the operator's default umask is. umask 077 : "${BACKUP_DATABASE_URL:?BACKUP_DATABASE_URL is required}" : "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT is required}" : "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET is required}" : "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}" : "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required}" # The hooks are a pair. A quiesce command without its resume command would # leave the operator's app stopped after every run (and a resume command # without a quiesce command would start containers nobody stopped), so refuse # before anything else happens. if [ -n "${BACKUP_QUIESCE_CMD:-}" ] && [ -z "${BACKUP_RESUME_CMD:-}" ]; then echo "backup: BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not; set both or neither" >&2 exit 2 fi if [ -n "${BACKUP_RESUME_CMD:-}" ] && [ -z "${BACKUP_QUIESCE_CMD:-}" ]; then echo "backup: BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not; set both or neither" >&2 exit 2 fi export AWS_DEFAULT_REGION="${BACKUP_S3_REGION:-us-east-1}" PREFIX="${BACKUP_S3_PREFIX:-accounted}" LABEL="${BACKUP_LABEL:-nightly}" # The label becomes S3 keys, file names and container arguments: keep it to # the same character set restore.sh accepts. if ! [[ "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "backup: invalid BACKUP_LABEL \"$LABEL\" (letters, digits, . _ - only)" >&2 exit 2 fi STAMP="$(date -u +%Y%m%dT%H%M%SZ)" NAME="${LABEL}-${STAMP}" for bin in pg_dump psql tar gzip aws; do command -v "$bin" >/dev/null 2>&1 || { echo "backup: missing required tool: $bin" >&2; exit 2; } done if command -v sha256sum >/dev/null 2>&1; then SHA="sha256sum"; else SHA="shasum -a 256"; fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" WORK="${BACKUP_WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/accounted-backup.XXXXXX")}" mkdir -p "$WORK" chmod 700 "$WORK" # Flipped to 1 BEFORE the quiesce hook runs, not after it succeeds: a hook # that stops one container and then fails ends the script (set -e), and the # EXIT trap must still run the resume hook, otherwise a failed backup leaves # the app stopped. QUIESCE_ATTEMPTED=0 cleanup() { if [ "$QUIESCE_ATTEMPTED" = 1 ] && [ -n "${BACKUP_RESUME_CMD:-}" ]; then bash -c "$BACKUP_RESUME_CMD" || echo "backup: BACKUP_RESUME_CMD failed; check that the app is running" >&2 fi [ -z "${BACKUP_WORKDIR:-}" ] && rm -rf "$WORK" } trap cleanup EXIT upload() { # upload local file="$1" key="$2" local args=(s3api put-object --endpoint-url "$BACKUP_S3_ENDPOINT" --bucket "$BACKUP_S3_BUCKET" --key "$key" --body "$file") if [ -n "${BACKUP_OBJECT_LOCK_DAYS:-}" ]; then local until # GNU date and BSD date differ; try GNU first. until="$(date -u -d "+${BACKUP_OBJECT_LOCK_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v+"${BACKUP_OBJECT_LOCK_DAYS}"d +%Y-%m-%dT%H:%M:%SZ)" args+=(--object-lock-mode COMPLIANCE --object-lock-retain-until-date "$until") fi aws "${args[@]}" >/dev/null echo "backup: uploaded s3://${BACKUP_S3_BUCKET}/${key}" } echo "backup: starting ${NAME}" if [ -n "${BACKUP_QUIESCE_CMD:-}" ]; then QUIESCE_ATTEMPTED=1 bash -c "$BACKUP_QUIESCE_CMD" echo "backup: application quiesced for a consistent set" fi # 1. Database: custom-format dump (compressed, selective restore possible). # --no-owner so it restores into a fresh Supabase stack whose roles were # created by that stack, not by us. ACLs (GRANT/REVOKE) are kept (no # --no-privileges): the migrations lock SECURITY DEFINER RPCs away from # anon/authenticated, and the dump is the only record of that. Keeping them # is necessary but not sufficient: pg_dump writes ACLs as a diff against # PostgreSQL's built-in defaults, so a role-specific "REVOKE ... FROM anon" # is not in the dump at all, and a Supabase target's ALTER DEFAULT # PRIVILEGES would hand anon/authenticated EXECUTE back to every restored # function. restore.sh neutralizes those default privileges before # pg_restore and then diffs the ACL manifest taken in step 1b against the # restored database, so a re-exposure fails the restore instead of hiding. DB_FILE="${WORK}/${NAME}.db.dump" pg_dump --format=custom --no-owner --file "$DB_FILE" "$BACKUP_DATABASE_URL" echo "backup: database dump $(du -h "$DB_FILE" | cut -f1)" # 1b. ACL manifest: what anon/authenticated/service_role may do with every # function, relation and sequence in public # (scripts/self-host/acl-manifest.sql). # restore.sh compares the restored database against this file. ACL_FILE="${WORK}/${NAME}.acl.txt" psql -X -A -t -q -v ON_ERROR_STOP=1 -f "${SCRIPT_DIR}/acl-manifest.sql" "$BACKUP_DATABASE_URL" > "$ACL_FILE" echo "backup: ACL manifest $(wc -l < "$ACL_FILE" | tr -d ' ') objects" # 2. Documents (the BFL underlag) when storage-api uses the file backend. STORAGE_FILE="" if [ -n "${BACKUP_STORAGE_DIR:-}" ]; then if [ ! -d "$BACKUP_STORAGE_DIR" ]; then echo "backup: BACKUP_STORAGE_DIR does not exist: $BACKUP_STORAGE_DIR" >&2 exit 2 fi STORAGE_FILE="${WORK}/${NAME}.storage.tar.gz" tar -C "$BACKUP_STORAGE_DIR" -czf "$STORAGE_FILE" . echo "backup: storage archive $(du -h "$STORAGE_FILE" | cut -f1)" fi # 3. Supabase db-config volume (pgsodium root key), optional but strongly # recommended: without it Vault-encrypted columns in the dump are # unreadable after a restore. DBCONFIG_FILE="" if [ -n "${BACKUP_DB_CONFIG_VOLUME:-}" ]; then command -v docker >/dev/null 2>&1 || { echo "backup: BACKUP_DB_CONFIG_VOLUME set but docker not found" >&2; exit 2; } DBCONFIG_FILE="${WORK}/${NAME}.db-config.tar.gz" docker run --rm -v "${BACKUP_DB_CONFIG_VOLUME}:/src:ro" -v "${WORK}:/out" alpine:3 \ tar -C /src -czf "/out/$(basename "$DBCONFIG_FILE")" . echo "backup: db-config archive $(du -h "$DBCONFIG_FILE" | cut -f1)" fi # 4. Checksums, then upload everything under one prefix. MANIFEST="${WORK}/${NAME}.sha256" ( cd "$WORK" && $SHA "$(basename "$DB_FILE")" "$(basename "$ACL_FILE")" \ ${STORAGE_FILE:+"$(basename "$STORAGE_FILE")"} \ ${DBCONFIG_FILE:+"$(basename "$DBCONFIG_FILE")"} > "$MANIFEST" ) for f in "$DB_FILE" "$ACL_FILE" ${STORAGE_FILE:+"$STORAGE_FILE"} ${DBCONFIG_FILE:+"$DBCONFIG_FILE"} "$MANIFEST"; do upload "$f" "${PREFIX}/${NAME}/$(basename "$f")" done echo "backup: done ${NAME}"