diff --git a/.gitignore b/.gitignore index 24f0cd2..dc70e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ /dev/redis/data/ /dev/stripe-stub/.env /dev/bandwidth-worker/.env + +# Backup service secrets (DB passwords + borg passphrase) +/backup/.env diff --git a/README.md b/README.md index 149b9d0..f3f32d7 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,47 @@ After editing a function, `cd /docker/supabase && docker compose restart functio - `supabase-edge-functions` reads `REDIS_URL` from `/docker/supabase/.env`. - bandwidth-worker polls `http://frps:7500/api/proxy/http` every 60s; deltas go to `public.usage_samples` and `public.tunnels.bytes_used` via PostgREST. + +## Backups + +Daily encrypted backups via **borgmatic** (`/docker/backup`). + +| Service | Compose dir | Container | Schedule | Image | +|-----------|-----------------|-----------|---------------------|------------------------------------------------| +| borgmatic | /docker/backup | borgmatic | daily 03:00 (Berlin)| ghcr.io/borgmatic-collective/borgmatic:latest | + +- **Source:** `/docker` (mounted read-only). **Repository:** `/var/backups/borg` + (local host disk for v1; remote planned later). **Encryption:** repokey-blake2. +- **Databases:** prod (`supabase-db`) + dev (`supabase-dev-db`) captured via + `pg_dumpall` (roles + all DBs), streamed into the archive. The container is + attached to `supabase_default` + `supabase-dev_default` to reach them. +- **Excluded:** live PG data dirs (`supabase/volumes/db/data`), logs, + `caddy/.../autosave.json`, `.remediation-backup`, `node_modules`, `.next`. +- **Retention:** keep_daily 7, keep_weekly 4, keep_monthly 6. +- **Schedule:** built-in cron via `BACKUP_CRON` env (`0 3 * * *`), running + `borgmatic --stats -v 0` (default action set: prune, compact, create, check). +- **Secrets:** `/docker/backup/.env` (chmod 600, gitignored) holds + `POSTGRES_PASSWORD_PROD`, `POSTGRES_PASSWORD_DEV`, `BORG_PASSPHRASE`. + **Store `BORG_PASSPHRASE` offsite — losing it makes the repo unrecoverable.** + +### Operations + +```sh +cd /docker/backup +docker compose up -d # start the scheduler +docker compose run --rm --entrypoint borgmatic borgmatic list # archives +docker compose run --rm --entrypoint borgmatic borgmatic create --stats # manual backup +docker compose run --rm --entrypoint borgmatic borgmatic check # verify integrity +``` + +Restore a database dump: + +```sh +cd /docker/backup +ARCH=$(docker compose run --rm --entrypoint borgmatic borgmatic list --json \ + | python3 -c "import sys,json;print(json.load(sys.stdin)[0][\"archives\"][-1][\"archive\"])") +docker compose run --rm -v /tmp/restore:/restore --workdir /restore \ + --entrypoint borgmatic borgmatic extract --archive "$ARCH" \ + --path "borgmatic/postgresql_databases/supabase-db:5432/all" +# then: psql -U postgres -f /tmp/restore/borgmatic/postgresql_databases/supabase-db:5432/all +``` diff --git a/backup/.gitignore b/backup/.gitignore new file mode 100644 index 0000000..4cd5d48 --- /dev/null +++ b/backup/.gitignore @@ -0,0 +1,2 @@ +# Never commit secrets. +.env diff --git a/backup/borgmatic.d/config.yaml b/backup/borgmatic.d/config.yaml new file mode 100644 index 0000000..d036cc4 --- /dev/null +++ b/backup/borgmatic.d/config.yaml @@ -0,0 +1,74 @@ +# borgmatic configuration for linumiq.net — local encrypted daily backup (v1). +# borgmatic 2.x flat schema. Secrets are interpolated from the container +# environment (see ../.env): POSTGRES_PASSWORD_PROD, POSTGRES_PASSWORD_DEV, +# BORG_PASSPHRASE. + +# --------------------------------------------------------------------------- +# What to back up (files) +# --------------------------------------------------------------------------- +source_directories: + - /docker + +# Repository (bind-mounted from host /var/backups/borg). +repositories: + - path: /repository + label: linumiq-local + +# Encryption passphrase (repokey-blake2). Set in ../.env, kept offsite too. +encryption_passphrase: ${BORG_PASSPHRASE} + +# --------------------------------------------------------------------------- +# Exclusions: raw live PG data (captured via logical dumps below), transient +# logs, regenerable build artifacts, and remediation snapshots. +# --------------------------------------------------------------------------- +exclude_patterns: + - /docker/supabase/volumes/db/data + - /docker/dev/supabase/volumes/db/data + - /docker/supabase/volumes/logs + - /docker/dev/supabase/volumes/logs + - /docker/caddy/config/caddy/autosave.json + - sh:/docker/**/.remediation-backup + - sh:/docker/**/node_modules + - sh:/docker/**/.next +exclude_caches: true +exclude_if_present: + - .nobackup + +# Keep the archive listing manageable. +one_file_system: false + +# --------------------------------------------------------------------------- +# Postgres logical dumps (streamed into the archive, not written to disk). +# name "all" => pg_dumpall (roles + all databases + extensions). +# --------------------------------------------------------------------------- +postgresql_databases: + - name: all + hostname: supabase-db + port: 5432 + username: postgres + password: ${POSTGRES_PASSWORD_PROD} + - name: all + hostname: supabase-dev-db + port: 5432 + username: postgres + password: ${POSTGRES_PASSWORD_DEV} + +# --------------------------------------------------------------------------- +# Retention +# --------------------------------------------------------------------------- +keep_daily: 7 +keep_weekly: 4 +keep_monthly: 6 + +# Tag archives so prune groups them correctly. +archive_name_format: "linumiq-{now:%Y-%m-%dT%H:%M:%S}" +match_archives: "sh:linumiq-*" + +# --------------------------------------------------------------------------- +# Consistency checks (run as part of the default daily action set). +# --------------------------------------------------------------------------- +checks: + - name: repository + - name: archives + +# Surface command output/errors in logs. diff --git a/backup/docker-compose.yml b/backup/docker-compose.yml new file mode 100644 index 0000000..588fe9d --- /dev/null +++ b/backup/docker-compose.yml @@ -0,0 +1,40 @@ +name: backup +services: + borgmatic: + image: ghcr.io/borgmatic-collective/borgmatic:latest + container_name: borgmatic + hostname: linumiq-borgmatic + restart: unless-stopped + security_opt: + - no-new-privileges:true + environment: + TZ: Europe/Berlin + # Daily backup at 03:00 server-local time. The image writes this to + # /etc/crontabs/root and runs: borgmatic --stats -v 0 + BACKUP_CRON: "0 3 * * *" + env_file: + - ./.env + volumes: + # Backup source (read-only — borgmatic never writes to /docker). + - /docker:/docker:ro + # Local borg repository (host-side, same disk; remote planned later). + - /var/backups/borg:/repository + # borgmatic configuration. + - ./borgmatic.d:/etc/borgmatic.d:ro + # Persist borg cache + security dir across container recreation so borg + # does not re-scan the whole repo or warn about an "unknown" repository. + - borg_cache:/root/.cache/borg + - borg_security:/root/.config/borg + networks: + # Reach the prod + dev Postgres containers (supabase-db / supabase-dev-db) + # by container name for the pg_dumpall hook. + - supabase_default + - supabase-dev_default +volumes: + borg_cache: + borg_security: +networks: + supabase_default: + external: true + supabase-dev_default: + external: true