Skip to content

Backups and Restore

FreeSDN ships two distinct backup mechanisms with different scopes. Using the wrong one during an incident is costly - understand each before you need them.

MechanismWhat it capturesUse case
pg-backup (DB dumps)Full Postgres state: users, audit log, device inventory, encrypted credentials, agent registry, plugin state, all module dataDisaster recovery - restore a complete instance
Configuration Backup module (.fsdn archives)Portable config snapshot: Sites, Controllers, Devices, users, automation rules. No credentials.Migration between instances, dev-to-prod copy, config version history

The freesdn-pg-backup container is part of the core stack - no profile required. It dumps both databases daily:

  • freesdn - the primary PostgreSQL DB (19 schemas: access, agents, ai, analytics, audit, backup, cameras, collector, core, devices, enterprise, events, fabric, firewall, gateway, hypervisor, network, voip, vpn)
  • freesdn_logs - the TimescaleDB observability DB

Dumps land in the pg_backups volume with 7-day local retention.

By default, pg-backup fail-closes without GPG configured: it refuses to write unencrypted dumps. This applies to every deployment tier - the guard checks only whether GPG is active, not ENVIRONMENT. Any stack that lacks a valid GPG recipient must set BACKUP_ALLOW_PLAINTEXT=1 explicitly (the homelab .env.lite template includes a commented-out opt-in; uncomment it only if you have no GPG key and understand the risk - pro and max tier templates include GPG placeholder values that you must fill in before running - generate a dedicated keypair, set BACKUP_GPG_RECIPIENT to the real recipient email, and place the exported public key at ./secrets/backup-public.asc - and must not set this flag). Configure a GPG recipient before running in production.

Setup:

  1. On a secure restore host (not the production server), generate a dedicated backup key pair:

    Terminal window
    gpg --full-generate-key
    # Choose RSA 4096, no expiry
    gpg --export --armor backup@example.com > backup-public.asc
  2. Copy backup-public.asc to ./secrets/backup-public.asc in the repo.

  3. Set in your env file:

    Terminal window
    BACKUP_GPG_RECIPIENT=backup@example.com
    BACKUP_GPG_PUBLIC_KEY_PATH=./secrets/backup-public.asc

The container holds only the public key and cannot decrypt its own dumps. Store the private key and its passphrase in a secrets manager completely separate from the production server.

The scheduled dump runs every 24 hours. To force one immediately:

Terminal window
docker compose --env-file .env.pro exec pg-backup bash -c '
STAMP=$(date +%Y%m%d_%H%M%S)
pg_dump -h postgres -U "$PGUSER" -d "${POSTGRES_DB:-freesdn}" --no-owner --no-privileges \
| gzip > /backups/manual_freesdn_$STAMP.sql.gz
pg_dump -h logdb -U "$LOGDB_USER" -d "$LOGDB_DB" --no-owner --no-privileges \
| gzip > /backups/manual_logdb_$STAMP.sql.gz
ls -lh /backups/manual_*.sql.gz
'
Terminal window
# List all dumps in the backup volume
docker compose --env-file .env.pro exec pg-backup ls -lh /backups
# Copy a GPG-encrypted dump to the host (production - files end in .sql.gz.gpg)
docker compose --env-file .env.pro exec pg-backup cat /backups/freesdn_20260520_030000.sql.gz.gpg \
> ./freesdn_20260520_030000.sql.gz.gpg
# Copy a plaintext dump to the host (dev/homelab with BACKUP_ALLOW_PLAINTEXT=1 - files end in .sql.gz)
docker compose --env-file .env.pro exec pg-backup cat /backups/freesdn_20260520_030000.sql.gz \
> ./freesdn_20260520_030000.sql.gz

Enable the dr Compose profile to run a sidecar that syncs encrypted dumps off-site using rclone. See Compose Profiles for setup steps.

Off-site retention defaults to 30 days, managed independently from local retention. Use a bucket with object-lock / write-once policy to protect against ransomware.

Default RPO with the shipped daily-dump model: up to 24 hours. For tighter recovery points, configure WAL streaming as described in the DR procedure docs.

The Configuration Backup module (at /backup in the UI) produces portable .fsdn archives. Use them to:

  • Migrate configuration from a staging instance to production
  • Snapshot configuration before a major change
  • Seed a new Site with an existing Site’s configuration

A .fsdn archive carries no credentials. After restoring a config archive to a new instance, re-enter all module secrets (Controller passwords, NVR credentials, etc.).

The module supports selective restore (individual sections), strict semver schema gating (a mis-versioned section is rejected, not silently applied), and automatic rollback slots (a pre-restore snapshot is captured before any non-dry-run restore).

Full restore from scratch. Estimated time: 30-60 minutes for DB-only loss; 3-4 hours for total data-center loss including provisioning.

Terminal window
# 1. Stop write surfaces (keep DBs running if they exist)
docker compose --env-file .env.pro stop api worker worker-io scheduler
# 2. Drop and recreate the target databases
docker compose --env-file .env.pro exec postgres \
psql -U "$POSTGRES_USER" -d postgres -c \
"DROP DATABASE IF EXISTS freesdn; CREATE DATABASE freesdn OWNER $POSTGRES_USER;"
docker compose --env-file .env.pro exec logdb \
psql -U "$LOGDB_USER" -d postgres -c \
"DROP DATABASE IF EXISTS freesdn_logs; CREATE DATABASE freesdn_logs OWNER $LOGDB_USER;"
# 3. Restore from the dumps
# Production (GPG-encrypted, .sql.gz.gpg): decrypt first, then pipe into psql.
# The private key must be available on the restore host (not the production server).
gpg --decrypt ./freesdn_20260520_030000.sql.gz.gpg | gunzip -c | \
docker compose --env-file .env.pro exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
gpg --decrypt ./logdb_20260520_030000.sql.gz.gpg | gunzip -c | \
docker compose --env-file .env.pro exec -T logdb \
psql -U "$LOGDB_USER" -d "$LOGDB_DB"
# Dev/homelab only (BACKUP_ALLOW_PLAINTEXT=1, .sql.gz - never in production):
# gunzip -c ./freesdn_20260520_030000.sql.gz | \
# docker compose --env-file .env.pro exec -T postgres \
# psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
#
# gunzip -c ./logdb_20260520_030000.sql.gz | \
# docker compose --env-file .env.pro exec -T logdb \
# psql -U "$LOGDB_USER" -d "$LOGDB_DB"
# 4. Run migrations (detects existing schema, stamps to head - safe to re-run)
docker compose --env-file .env.pro up -d api worker worker-io scheduler
docker compose --env-file .env.pro exec api python scripts/migrate.py
# 5. Invalidate stale sessions (forces re-login - required after every restore)
docker compose --env-file .env.pro exec postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c \
"TRUNCATE core.user_sessions;
UPDATE core.users SET token_version = COALESCE(token_version, 0) + 1;"

Always run this after any restore:

Terminal window
BASE=https://freesdn.example.com
# Health checks must pass before allowing traffic
curl -fsS "$BASE/api/v1/health/live"
curl -fsS "$BASE/api/v1/health/ready"
# Audit chain integrity - the most important check
# Requires super_admin role. Use a super_admin Bearer token or super_admin session cookie.
# -f is omitted so a 403 body is visible if the wrong role is used.
curl -sS -H "Authorization: Bearer <super_admin_token>" "$BASE/api/v1/audit/validate?limit=100000" | jq
# Expected: {"valid": true, "broken_at": null, ...}

valid: false with broken_reason: "tampered" indicates either a partial commit during restore or genuine tampering. Cross-reference your off-site immutable backup before taking further action.

ScenarioDefault RPOTarget RTO
Daily dump, local restoreUp to 24 hours30-60 min (DB-only loss) / 3-4 hours (total loss)
Daily dump + off-site syncUp to 24 hours3-4 hours (includes downloading the off-site dump)
WAL streaming (operator-configured)~5 minutes3-4 hours + WAL replay time

These are planning targets, not contractual SLAs. Define and validate your own RPO/RTO with quarterly restore drills.

Next steps: High Availability - Valkey automatic failover and the Postgres standby topology.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.