Backup
The backup subsystem ships two distinct export paths plus one plaintext-only import path. Worth understanding the difference before you script around either.
| Path | What it returns | Use it when |
|---|---|---|
POST /api/v1/backup/export (no body) |
Plaintext JSON, sanitized. Sanitize() strips every credential — bridge PSKs, tunnel private_key, secrets table values, all sensitive kv_settings rows. Response header X-Gateway-Backup-Sanitized: true. |
Inspection, audit, sharing with reviewers who don’t need secrets. |
POST /api/v1/backup/export-encrypted body: {passphrase} |
AES-256-GCM ciphertext with PBKDF2-HMAC-SHA256 key (100 000 iterations, per-export salt). Includes secrets. GCM tag detects tampering / wrong passphrase on decrypt. | The real “lossless backup of everything” path. Use this for off-box archives. |
POST /api/v1/backup/import body: plaintext JSON |
Restores from a plaintext JSON payload. Transactional — wrong shape or schema mismatch rolls back the whole thing. | Restoring a sanitized export, or a plaintext blob produced by manually decrypting an encrypted export. |
There is no encrypted-import endpoint. To restore from an encrypted export, decrypt it yourself first (via the backup.Decrypt package helper or any tool that can do AES-256-GCM + PBKDF2-HMAC-SHA256 with the magic-V2 header) and POST the resulting plaintext JSON to /backup/import. This asymmetry is real and unfixed today.
What’s in an export
Section titled “What’s in an export”Both export paths cover the same runtime configuration scope:
- Routing policies and route profiles
- Exception manager state (active exceptions with remaining TTL)
- Devices and device groups
- Privacy classes and trust classes
- DNS rules, zones, forwarders, presets
- Bridge definitions (endpoints, transport, domain mappings)
- Alert rules
- Federation peer rows
kv_settings
The difference is Sanitize() — applied to /backup/export but not to /backup/export-encrypted. Sanitize strips: tunnel private_key, bridge psk, secrets-table values, telegram_bot_token, webhook_url, secrets_passphrase, admin_password_hash, mesh / node API keys, plus every kv row matching the _token / _secret / _password / _api_key / _psk suffix list per internal/backup/backup.go:273-281. Restoring a sanitized backup on a clean box re-prompts the operator for the admin password and re-issues secrets that have to be re-entered manually.
Take a backup
Section titled “Take a backup”gwctl backup export calls the plaintext sanitized endpoint — output is JSON with secrets stripped, no encryption, no passphrase:
gwctl backup export > /var/backups/gateway-$(date +%F).jsonFor the encrypted-with-secrets variant, go through the REST API:
curl -X POST https://gateway.lan:8080/api/v1/backup/export-encrypted \ -H "Authorization: Bearer $API_KEY" \ -d '{"passphrase":"REDACTED"}' \ --output /var/backups/gateway-$(date +%F).encThe daemon never logs or persists the passphrase. Lose it, lose the backup.
Scheduling, retention and remote push are managed at runtime via PUT /api/v1/backup/config ({enabled, interval_hours, remote_target} — see recipes). The scheduler produces sanitized JSON, not encrypted blobs.
Restore
Section titled “Restore”gwctl backup import <file.json> reads a plaintext JSON file as a positional argument (not stdin), POSTs to /backup/import, and applies inside a single SQLite transaction. If anything fails (schema mismatch, unique-key conflict, dangling reference), the entire restore is rolled back and the daemon continues on its prior state:
gwctl backup import /var/backups/gateway-2026-06-26.jsonFor an encrypted backup, decrypt first and pipe the plaintext through:
# (decrypt with whichever tool wraps backup.Decrypt for you) > /tmp/decrypted.jsongwctl backup import /tmp/decrypted.jsonSelective restores (single config kind) are done from the Backup → Restore UI page, which exposes per-kind toggles.
Config export vs. full backup
Section titled “Config export vs. full backup”The daemon exposes a second, narrower export path under /api/v1/config/* — useful for sharing inspection-safe configuration with another operator, or for migrating a specific gateway’s settings without the full audit / log / live-state baggage that the backup format carries.
| Path | Format | Includes sensitive kv_settings? |
Use it for |
|---|---|---|---|
POST /api/v1/backup/export |
encrypted blob | Yes (under your passphrase) | The canonical “full daemon state” snapshot. |
GET /api/v1/config/export |
plaintext JSON | No (filtered via IsSensitiveKVKey) |
Inspection, sharing, source-control friendly diffs. |
POST /api/v1/config/export-encrypted |
encrypted blob | Yes (everything except jwt_*) |
Moving a curated config between gateways with full secrets. |
POST /api/v1/config/import |
plaintext JSON only | — | Apply a /config/export payload. Encrypted import is via /backup/import. |
The encrypted config export uses the same AES-256-GCM + PBKDF2-HMAC-SHA256 (100k iter) helper as backups, so a gateway-config.enc file produced by /config/export-encrypted is structurally compatible with backup.Decrypt — pre-shared knowledge worth having when scripting recovery.
The plaintext /config/export is safe to share only if you trust the IsSensitiveKVKey filter. It redacts every key matching the suffixes _token, _secret, _password, _api_key, _psk, plus the explicit names telegram_bot_token, webhook_url, secrets_passphrase, mesh_api_key, node_api_key, and admin_password_hash. The encrypted variant additionally filters jwt_* keys (session-signing keys are per-host and not portable). When in doubt, use the encrypted variant.
Integrity verification
Section titled “Integrity verification”GCM’s authentication tag is what verifies integrity. An attacker who flips a byte in the ciphertext fails the tag check on decrypt; an attacker who supplies the wrong passphrase fails the same check. There is no separate hash sidecar.
On import the daemon:
- Reads the salt header, derives the AES key with PBKDF2.
- Decrypts; GCM-authenticates in the same step.
- Parses the JSON, validates each object against the schema.
- Applies in a single SQLite transaction.
A failure at any step aborts with a non-zero exit code and an audit log entry. There’s no “partially restored” state.
API surface
Section titled “API surface”| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/backup/export |
Encrypted full-state export (returns ciphertext) |
POST |
/api/v1/backup/import |
Decrypt + apply |
GET |
/api/v1/backup/history |
Past backup runs (local + remote) |
PUT |
/api/v1/backup/schedule |
Edit the schedule |
GET |
/api/v1/config/export |
Plaintext JSON export, sensitive kv redacted |
POST |
/api/v1/config/export-encrypted |
Encrypted curated export with secrets |
POST |
/api/v1/config/import |
Apply a plaintext config export |
Next steps
Section titled “Next steps”After your first export lands on disk:
- Verify the restore. Pull the file to a throwaway VM and
gwctl backup importit. The worst time to discover a corrupted backup is during an actual incident. - Push remotely. Configure SCP/SFTP via
PUT /api/v1/backup/configso a single-disk failure doesn’t take both daemon and backup with it. - Wire the
backup_failedalert to a paging channel. A silent failure for a week is the same as no backup at all. - Rotate the passphrase quarterly. Run a new export with the new passphrase, keep one cycle of old-passphrase backups for safety, verify the restore under the new passphrase, retire the old one.
Related
Section titled “Related”- Configuration — bootstrap vs runtime; restore only restores runtime.
- Federation — federation is not a backup; secondaries can be wiped together with the primary if the underlying config is bad.
- Alerts & monitoring — the built-in
backup-failedrule fires when a scheduled run fails. - Recipes → Backup — schedule + restore snippets.