Skip to content

Configuration Backup

The Configuration Backup module (id backup) exports your FreeSDN instance into a self-contained archive that you can import on any FreeSDN instance. It produces two formats:

  • .fsdn: configuration snapshot. Config only (sites, controllers, devices, users, automation); no secrets. Encrypted under the source instance’s SECRET_KEY, so it is safe to share and ideal for config portability and version history, but it is not portable across instances on its own, and it is not disaster recovery.
  • .fsdnvault: secure full backup. Carries every credential (controller passwords, PBX/camera/firewall secrets, VPN configs, user password hashes) sealed under an operator passphrase rather than the instance key, so it is portable, and it is re-keyed onto the destination instance’s SECRET_KEY on restore. This is the format for instance migration and bare-metal / first-install recovery.

Both formats share the same machinery: scheduling, eight storage backends, encrypted archives, selective restore, and automatic rollback slots.

What a config snapshot includes and excludes

Section titled “What a config snapshot includes and excludes”

The archive is assembled from registered contributors - pluggable units each responsible for one data domain. The CoreBackupContributor covers the domains below. Module-provided contributors add their own domain data.

Domain Included by default Notes
Organizations and sites Yes
Controllers (connection config, not credentials) Yes Credentials are instance-specific; re-add after import
Managed devices Yes Toggle with include_devices
VLANs and SSIDs No Not available in this release - include_vlans / include_ssids are accepted by the API but have no effect in v1.1.0; no VLAN or SSID data is collected or restored
Automation rules Yes Toggle with include_automation
Backup schedules and storage locations No Not included in the portable snapshot; reconfigure schedules and storage locations on the destination instance
Users On by default Pass include_users: false at create time to exclude from the snapshot. restore_users defaults to false - opt in with restore_users: true to restore user records.
Encrypted credentials Never Fernet-encrypted under the source SECRET_KEY; not portable
Audit logs Never Instance-tied; use pg-backup
Plugin code and install state Never
Operational telemetry and time-series metrics Never

The archive uses a versioned binary envelope (format version "2.0"):

[4 bytes: header length, big-endian uint]
[N bytes: JSON header - backup_id, SHA-256 checksum of payload, encrypted flag,
compressed flag, created_at, version]
[remaining: gzip-compressed JSON payload, optionally Fernet-encrypted]

The creation pipeline is: collect via contributor registry → gzip → optional Fernet-encrypt → SHA-256 checksum → write header and payload → store via the configured storage backend.

Snapshots are encrypted with Fernet, keyed from a PBKDF2-SHA256 derivation of your instance SECRET_KEY with a random 16-byte per-snapshot salt. PBKDF2 iteration count is 600,000 (OWASP 2025 recommendation). Encryption defaults to on (is_encrypted: true).

The key format is versioned (v2:<iterations>:<base64-salt>). Older v1 archives (100 k iterations, legacy format) are still decryptable; new snapshots always use v2.

A .fsdnvault is a full, portable, secret-bearing backup. Where .fsdn deliberately drops credentials, .fsdnvault carries every secret from every contributor - controller passwords and OAuth client secrets, PBX (*_enc) and voicemail secrets, camera/NVR credentials, firewall connection credentials, VPN config material, and user password hashes - so the destination comes up fully operational.

Portability comes from the key model:

  • Sealed under an operator passphrase, not the instance key. You supply a passphrase (minimum 12 characters) at create time. The payload is sealed with BackupEncryption(master_key=<passphrase>) - the same audited PBKDF2-SHA256 (600,000 iterations, v2) + Fernet primitive used for .fsdn, but keyed off your passphrase instead of SECRET_KEY.
  • Self-describing header. The PBKDF2 salt (key_id) rides in the file header, so the file decrypts on any instance given the passphrase - no origin-instance key material required.
  • Re-keyed on restore. Each secret is decrypted with the passphrase, then re-encrypted under the destination instance’s SECRET_KEY as it is written. The vault is never stored decrypted; the destination owns its own ciphertext afterward.

Create one by setting include_secrets: true and a passphrase (the UI exposes this as a Full backup toggle with a passphrase field and a crown-jewels warning):

POST /api/v1/backups/
Content-Type: application/json
Authorization: Bearer <token>
{
"name": "full-vault-2026-06",
"include_secrets": true,
"passphrase": "<operator passphrase, >= 12 chars>",
"storage_location_id": "<uuid>"
}

include_secrets: true forces is_encrypted on (a vault is never written in plaintext, even with BACKUP_ALLOW_PLAINTEXT=1). The output file is named *.fsdnvault; its header records secrets: true and the key_id.

First-install restore (bare-metal recovery)

Section titled “First-install restore (bare-metal recovery)”

A .fsdnvault powers a guided “recover from nothing” path - the same first choice UniFi, Home Assistant, and pfSense offer. On a brand-new instance with no organization or admin yet, the setup wizard’s first step offers “Restore from a backup instead”: upload a .fsdnvault, enter its passphrase, and FreeSDN rebuilds the instance - it recreates the Organization with its original ID (so the archive’s org-scoped records are accepted, not rejected as cross-tenant), restores every contributor including secrets (re-keyed onto this instance), and the restored super-admin completes setup. You then log in with your original credentials.

POST /api/v1/setup/restore
Content-Type: multipart/form-data
file=<.fsdnvault file>
passphrase=<operator passphrase>
org_name=<optional display-name override>

This endpoint is gated to the first-run window only - it is callable just while no super-admin exists (the same guard as the rest of the setup wizard) and 404s afterward. For full-DB disaster recovery of an instance that is already set up (audit history, time-series, etc.), use the out-of-band pg-backup Postgres restore in Backups and Restore instead.

The backup module is always loaded. No additional Compose services are required - it runs inside the existing api and worker containers using the backup PostgreSQL schema. Navigate to Config Backup in the left sidebar.

Config Backup → New Snapshot in the UI, or via the API:

POST /api/v1/backups/
Content-Type: application/json
Authorization: Bearer <token>
{
"name": "pre-migration-2026-06",
"backup_type": "full",
"include_devices": true,
"include_vlans": true,
"include_ssids": true,
"include_users": false,
"include_automation": true,
"is_encrypted": true,
"storage_location_id": "<uuid>"
}

The backup runs asynchronously via Celery (soft limit 600 s, hard limit 720 s). Poll status at GET /api/v1/backups/{backup_id}. The status field steps through progress values: 5 → 30 → 50 → 60 → 70 → 90 → 100.

Requires SUPER_ADMIN or ORG_ADMIN role.

Config Backup → Schedules → New Schedule, or via the API:

POST /api/v1/backups/schedules
Content-Type: application/json
{
"name": "nightly-full",
"cron_expression": "0 2 * * *",
"storage_location_id": "<uuid>",
"retention_days": 30,
"max_backups": 10,
"is_encrypted": true
}
Field Range Default Notes
cron_expression Standard 5-field cron - Validated at creation; invalid cron expressions are rejected
max_backups 1-1,000 10 When exceeded, the oldest COMPLETED backup is pruned automatically
retention_days 1-3,650 30 Snapshots older than this are pruned by the daily cleanup task
is_encrypted bool true Keep encryption on - snapshots contain cross-org config data

The Celery beat scheduler (scheduler container) evaluates schedules every 15 minutes using SELECT … FOR UPDATE SKIP LOCKED to prevent double-firing across multiple workers. Missed runs are not replayed - the next eligible slot is chosen, which avoids thundering-herd after downtime.

Toggle a schedule without deleting it:

POST /api/v1/backups/schedules/{schedule_id}/toggle
Content-Type: application/json
{ "is_enabled": false }

For a lightweight pfSense-style JSON config download without creating a stored backup record:

GET /api/v1/backups/export?include_devices=true&include_vlans=true&include_ssids=true&include_users=false&include_automation=true&compress=true

Response is a downloadable file. Requires SUPER_ADMIN or ORG_ADMIN role.

Configure storage locations at Config Backup → Storage Settings → New Location, or at POST /api/v1/backups/storage-locations. Storage secrets (API keys, passwords, private keys, tokens) are Fernet-encrypted at rest - they must never be passed in the plaintext config field; the API enforces this with a schema-level guard.

Backend storage_type Notes
Local filesystem local Default base path /data/backups. Path-traversal guard applied. Atomic write (.tmp + rename).
NFS nfs NFS-mounted paths appear as local; use the same local backend class
Amazon S3 or S3-compatible s3 boto3; path-style addressing; endpoint host is resolved and pinned to an IP literal at create/test time (DNS-rebind safe)
SFTP sftp paramiko; host pinned; private_key_path must reside under the sandboxed FREESDN_BACKUP_KEYS_DIR
FTP ftp ftplib; optional FTPS (use_tls: true); host pinned
Google Drive google_drive Service-account JSON or OAuth refresh token
Dropbox dropbox Access token or refresh token + app key
WebDAV webdav httpx async; base URL resolved and pinned to IP literal; Host header injected for SNI/vhost routing
POST /api/v1/backups/storage-locations/{location_id}/test

This triggers a real outbound connectivity check. The endpoint is gated to SUPER_ADMIN or ORG_ADMIN to prevent SSRF probing by lower-privilege users.

List supported backends and config field schemas

Section titled “List supported backends and config field schemas”
GET /api/v1/backups/storage-locations/types/supported

Returns the full field schema for each backend type - the same data the frontend uses to render the dynamic configuration form.

Before restoring, inspect what a snapshot contains without decrypting any live data:

GET /api/v1/backups/{backup_id}/manifest

Returns a BackupManifestPreview with per-contributor sections, record counts, and restorability status. Requires SUPER_ADMIN.

POST /api/v1/backups/restore
Content-Type: application/json
{
"backup_id": "<uuid>",
"dry_run": true,
"restore_devices": true,
"restore_vlans": true,
"restore_ssids": true,
"restore_users": false,
"restore_automation": true,
"overwrite_existing": false,
"target_site_id": null
}

restore_users defaults to false - you must opt in. overwrite_existing defaults to false - existing rows are left untouched unless you set it to true.

The response is a RestoreJob. Poll GET /api/v1/backups/restore/{job_id} for status and the dry-run report.

Repeat the same request body with dry_run: false. Before applying any writes, the service automatically captures a pre-restore rollback slot (a full backup tagged backup_type=rollback_slot), encrypted and retained for 7 days. If the rollback-slot capture fails, the restore proceeds and the failure is logged - it is best-effort, not blocking.

To undo a restore, identify the rollback slot linked to the restore job via the rollback_for_restore_job_id field on the BackupResponse and restore from it using POST /api/v1/backups/restore. The Snapshot History list displays a badge on rollback-slot backups so you can find them visually. There is no dedicated “Undo” button in the UI - the mechanism is API-only.

Importing a .fsdn file from another instance

Section titled “Importing a .fsdn file from another instance”
POST /api/v1/backups/import?dry_run=true&overwrite_existing=false
Content-Type: multipart/form-data
file=<.fsdn file>

Only .fsdn files are accepted - raw JSON and gzip are rejected (they bypass the checksum verification). The upload is size-capped; files over the limit receive HTTP 413. Organization ownership is enforced: rows belonging to a foreign org are rejected. Blocked fields (id, hashed_password, and other privilege-escalation vectors) are stripped on both insert and update paths. Requires SUPER_ADMIN.

All endpoints require an active authenticated session. Org isolation is enforced in the service layer. The storage-locations sub-router is mounted before /{backup_id} to avoid the catch-all path consuming literal sub-paths.

Method Path Purpose Auth
GET /api/v1/backups/ List backups (filter by site_id, backup_type, status, storage_type, search; paginated) Authenticated
POST /api/v1/backups/ Create and execute a backup SUPER_ADMIN / ORG_ADMIN
GET /api/v1/backups/{backup_id} Get backup detail Authenticated
GET /api/v1/backups/{backup_id}/download Download the .fsdn file SUPER_ADMIN / ORG_ADMIN
DELETE /api/v1/backups/{backup_id} Delete backup record and storage file SUPER_ADMIN / ORG_ADMIN
GET /api/v1/backups/{backup_id}/manifest Preview per-contributor manifest without restoring SUPER_ADMIN
GET /api/v1/backups/stats Aggregate stats (site_id filter) Authenticated
GET /api/v1/backups/export Instant config download (no stored record) SUPER_ADMIN / ORG_ADMIN
POST /api/v1/backups/import Import a .fsdn file (multipart) SUPER_ADMIN
POST /api/v1/backups/restore Restore from a backup SUPER_ADMIN
GET /api/v1/backups/restore/{job_id} Poll restore job status Authenticated
Method Path Purpose Auth
GET /api/v1/backups/schedules List schedules Authenticated
POST /api/v1/backups/schedules Create schedule SUPER_ADMIN / ORG_ADMIN
GET /api/v1/backups/schedules/{schedule_id} Get schedule Authenticated
PUT /api/v1/backups/schedules/{schedule_id} Update schedule SUPER_ADMIN / ORG_ADMIN
DELETE /api/v1/backups/schedules/{schedule_id} Delete schedule SUPER_ADMIN / ORG_ADMIN
POST /api/v1/backups/schedules/{schedule_id}/toggle Enable or disable SUPER_ADMIN / ORG_ADMIN
Method Path Purpose Auth
GET /api/v1/backups/storage-locations/types/supported List backends and config field schemas Authenticated
GET /api/v1/backups/storage-locations List configured locations Authenticated
POST /api/v1/backups/storage-locations Create location (SSRF-validated) SUPER_ADMIN / ORG_ADMIN
GET /api/v1/backups/storage-locations/{location_id} Get one location Authenticated
PATCH /api/v1/backups/storage-locations/{location_id} Update location SUPER_ADMIN / ORG_ADMIN
DELETE /api/v1/backups/storage-locations/{location_id} Delete location SUPER_ADMIN / ORG_ADMIN
POST /api/v1/backups/storage-locations/{location_id}/test Test connectivity SUPER_ADMIN / ORG_ADMIN

The Celery beat scheduler runs a monthly per-org dry-run restore validation task (backup.validate_restore). It picks the newest COMPLETED backup per organization, runs a full dry_run=True restore (decrypts → verifies SHA-256 → parses → walks restore plan without writing), and records the result.

Each organization is given a 60-second timeout so a single stuck org cannot consume the entire validation budget. Results: ok, warn (no recent backup found), error, or timeout.

Permission code Required for
backup.view Browse snapshot history and view backup metadata
backup.create Trigger manual or on-demand snapshots
backup.restore Restore from a snapshot (SUPER_ADMIN enforced at the API layer)
backup.delete Delete snapshot records and storage files
backup.schedule Create and manage backup schedules
backup.settings Configure storage locations and encryption settings

backup.create, downloading .fsdn files, and managing storage locations require at minimum SUPER_ADMIN or ORG_ADMIN as enforced by the API. backup.restore and backup.import (i.e. POST /api/v1/backups/restore and POST /api/v1/backups/import) require SUPER_ADMIN only - ORG_ADMIN is not sufficient and will receive HTTP 403. The permission codes above describe the RBAC intent, but the restore and import endpoints add a hard SUPER_ADMIN-only role gate regardless of permission assignment. A user with only backup.view can browse snapshot history but will receive HTTP 403 on GET /api/v1/backups/{id}/download.

Event type Severity When
backup.validation.failed CRITICAL Monthly automated dry-run validation failed for one or more orgs

Wire this to fabric.notify (in-platform notification), a webhook, or an n8n workflow. See Fabric.

  • A .fsdn config snapshot never exports credentials. After importing a .fsdn onto a new instance, re-add all adapter credentials (API keys, passwords) via Settings → Credentials before running discovery or staging writes. A .fsdnvault secure full backup is the exception - it does carry every credential, re-keyed onto the destination on restore, so no re-entry is needed.
  • Missed schedule runs are not replayed. After the scheduler comes back up from downtime it picks the next eligible slot forward, not the missed windows. This is intentional to prevent a burst of simultaneous backups.
  • Schema version mismatches on restore. If a contributor’s data was written with a different major schema version, it is skipped with status schema_mismatch unless the contributor implements a MigratingContributor migration path. Check the manifest preview before restoring across major version gaps.
  • Rollback-slot capture is best-effort. The pre-restore snapshot is attempted but a failure is logged-and-skipped rather than blocking the restore. Verify that a rollback slot was created before assuming you can undo.
  • restore_users defaults to false. User records are not restored unless you explicitly opt in. This is a deliberate default to prevent overwriting accounts on the destination instance.
  • Decompression bomb guard. The import endpoint uses bounded decompression with a hard byte ceiling. Files that decompress beyond MAX_BACKUP_IMPORT_BYTES receive HTTP 413.
  • Not DR. If PostgreSQL is corrupted or lost, the .fsdn snapshot cannot recover audit-log history, time-series metrics, or operational telemetry. For that you need the PostgreSQL dump from the pg-backup container plus the dr Compose profile.
  • Set up off-site DR: Backups and Restore
  • Wire backup.validation.failed to notify your team: Fabric
  • Understand deployment tiers and the dr Compose profile: Deployment
  • Review the adapter credential model before importing onto a new instance: Adapters

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.