Files
Jakob Wennberg 79edee659f docs(self-host): sovereign Sverige guide, backup/restore scripts, Speed Insights gate (#1744)
docs/SOVEREIGN.md (run Accounted on Swedish infrastructure: providers, self-hosted Supabase gotchas, backup/restore runbook, honest dependency list), scripts/self-host/backup.sh + restore.sh (pg_dump custom format, storage tar, SHA-256 manifest, S3-compatible upload; ACLs are preserved through the restore and re-verified against an acl-manifest including sequences; the resume hook always runs after a failed quiesce and the hooks must be configured as a pair), Vercel Speed Insights gated off for self-hosted, and stale self-host docs corrected (assistant Q&A and categorization run on BYO OpenAI-compatible models; SMTP via EMAIL_PROVIDER=smtp after #1746; connector subscription described as proposed only).
2026-08-31 08:18:14 +01:00

200 lines
10 KiB
Bash
Executable File

#!/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:<password>@<host>:<port>/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: <supabase-dir>/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> <key>
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}"