Administration

Backup and recovery

Take encrypted database snapshots, restore them, and know what DFIRe covers and what you own.

This is a convenience feature, not your backup strategy.

Treat it as a way to undo a mistake quickly, such as a bad configuration change or an unwanted mass edit. It suits the moments when rolling back in minutes beats starting a full recovery. It does not replace the backups your platform provides. Keep running whichever of these suits your deployment:

  • VM or volume snapshots of the host, from the hypervisor or your cloud provider.
  • Managed database backups from your PostgreSQL host, or a scheduled pg_dump over a direct connection.
  • Container volume backups covering the PostgreSQL volume, the Redis volume and attachment storage.
  • Object storage versioning or replication, when attachments live in a bucket.

DFIRe's backup runs on the same host as DFIRe. Lose that host and you may lose the backups sitting on it. Platform-level backups are what survive a bad day.

What a backup contains

The backup system lives at Settings → Backup & Recovery and needs superuser access. Each backup is a complete PostgreSQL dump, encrypted, written to the target bound to the Backup role. It holds:

  • Cases, evidence items, notes, timers and investigation reports.
  • Indicators of compromise, enrichment data, and the TAXII and MISP configuration.
  • User accounts, roles, permissions and API keys.
  • Multi-factor material for enrolled users: authenticator seeds and unused recovery codes.
  • Audit logs up to the moment of the backup.
  • Configuration: workflow steps, playbooks, evidence types, runbooks, webhooks and tenant settings.
  • Timeline events, chain-of-custody records and compliance timer state.
  • Attachment metadata and the keys needed to decrypt the files on storage.

The files themselves are not in the backup. Encrypted attachments stay on the storage target. The backup carries the keys and metadata that make them readable. A complete recovery needs both.

How backups are encrypted

DFIRe derives the backup key from your SECRET_KEY using PBKDF2-HMAC-SHA256 with 600,000 iterations and a fresh 16-byte salt per backup. The dump is encrypted with AES-256-GCM in 8 MB chunks, each authenticated on its own.

DFIRe binds the 32-byte file header into every chunk as additional authenticated data. Editing the magic bytes, the format version, the salt or the reserved bytes therefore breaks the authentication tag on every chunk. After the header comes an encrypted metadata chunk, then the dump, then an encrypted end-of-file sentinel. The sentinel is how DFIRe detects truncation. A file cut at a chunk boundary would otherwise verify chunk by chunk and look intact.

SECRET_KEY is the master key to every backup. Lose it and no backup file can be decrypted. Keep it and CREDENTIAL_ENCRYPTION_KEY somewhere separate from the backup files: a password manager, a secrets manager, or secure offline storage. Both usually live in .env or in your platform's secrets configuration.

Creating backups

Where to run the commands. Use the installation directory, for example /opt/dfire. The bundle records the database overlay in .env, so one form works for both bundled and external PostgreSQL:

docker compose exec backend python manage.py <command>

If the directory still holds docker-compose.prod.yml, run the current installer there first. See Updating DFIRe.

On demand

Choose Create Backup Now in Backup & Recovery. It runs in the background and the page updates when it finishes, so you can keep working.

From the command line, create_backup runs synchronously and needs no task queue:

docker compose exec backend python manage.py create_backup
docker compose exec backend python manage.py create_backup --description "Before upgrading"

It prints the backup UUID, the storage path, the file size and the checksum.

On a schedule

Turn on scheduled backups in the same settings page and choose the frequency, from hourly through to weekly. Daily and weekly schedules also take a time of day, and weekly takes a day. The retention count decides how many backups to keep, and DFIRe deletes the oldest beyond it.

After saving, activate the schedule in the task queue:

docker compose exec backend python manage.py setup_schedules

A notifier subscribed to the Backup Finished event reports every run on both outcomes, whether it came from this page, the schedule or create_backup. See Webhooks and Email.

Managing backups

The list shows each backup's date, size, type and DFIRe version. It reads the database records, so use Refresh to compare it against the files on storage.

  • Validate decrypts every chunk, checks each authentication tag and the end-of-file sentinel, and compares the result against the stored checksum. A clean pass proves the file is intact, untampered, complete, and readable with this instance's SECRET_KEY.
  • Download streams the encrypted .enc file for offsite storage.
  • Restore replaces the current database. See Restoring a backup.
  • Delete removes the file and its record.

Upload Backup imports a .enc file you downloaded earlier. The upload decrypts every chunk and checks the sentinel before it creates any record. A file encrypted under a different SECRET_KEY, truncated, bit-flipped or altered in the header is rejected outright. That is deliberate: a successful upload has already passed the same checks the restore will run, so a broken backup cannot surprise you later.

Refresh rescans the storage target, drops records for files that have gone, and adds records for files it finds. A backup that failed never wrote a file, so Refresh clears its record too. Use it after changing storage targets, after a restore, or when files were added or removed outside DFIRe.

From the command line, list_backups prints the same information as a table and runs the storage sync first:

docker compose exec backend python manage.py list_backups
docker compose exec backend python manage.py list_backups --no-sync
docker compose exec backend python manage.py list_backups --json

This is how you find a backup UUID before restoring from the command line.

Restoring a backup

A restore is destructive. It drops and rebuilds the whole database, replacing every case, user and setting with the snapshot. You cannot undo it. Take a backup of the current state first.

Give DFIRe its own database. A restore clears everything in the public schema, including tables DFIRe did not create. Do not put another application's data there.

A restore either finishes or changes nothing. DFIRe clears and rebuilds inside one transaction, so a failure part-way leaves the existing database exactly as it was.

Restoring from an older DFIRe version works. The snapshot goes back as it was, then DFIRe applies the pending migrations to bring the schema up to the running version. A backup from a newer version is rejected, so upgrade first and then restore.

The database role DFIRe connects as has to own the public schema. It does in the standard Compose deployment. On a database you manage, where DFIRe may connect as some other role, the restore stops before changing anything and names the role that needs ownership.

A backup restores only onto the same PostgreSQL major version or a newer one. PostgreSQL does not support going backwards, and no tooling works around it. Move the database to an older major version and every backup taken before the move becomes unrestorable.

Moving to a newer PostgreSQL

Because a restore onto a newer major version works, backup and restore is a supported way to move DFIRe to a newer database server. Back up on the old server, stand up the new one, restore there.

Plan it in one direction. Backups taken on the newer server will not go back onto the old one. Keep the old server until you have confirmed the new one works.

Validating first

Validate decrypts and checksums a backup, then reports one of three answers.

  • Verified and restorable. The file is intact and nothing blocks a restore.
  • Cannot be restored on this server. The file is intact but a restore would fail. DFIRe lists why: a newer PostgreSQL major version, a newer DFIRe version, or a schema that disagrees with the migrations it records.
  • Could not be fully verified. Nothing blocks a restore, but one check did not run, so this is not a clean result.

Validation reports the DFIRe version, the PostgreSQL version, the storage target and a SHA-256 you can compare against a downloaded copy.

The same answer is available from the command line. It exits non-zero unless the backup is restorable, so a recovery script can branch on it:

docker compose exec backend python manage.py validate_backup <uuid>
docker compose exec backend python manage.py validate_backup <uuid> --json

list_backups cannot tell you whether a backup is restorable. That depends on the PostgreSQL version it was taken on, which lives inside the encrypted archive rather than in the database.

Restoring from the web interface

Choose the restore icon on a completed backup and confirm. DFIRe revokes every other session and shows a blocking overlay to everyone connected, including you. It then rebuilds the database from the snapshot, applies any migrations the running version needs, and sends everyone to the login page.

The restore runs in the background. Closing the browser does not stop it, and reloading brings the overlay back with the current status.

Restoring from the command line

For a recovery where the web interface is not available:

# Prompts for confirmation
docker compose exec backend python manage.py restore_backup <backup-uuid>

# For scripts
docker compose exec -T backend python manage.py restore_backup <backup-uuid> --yes

The command validates the backup, shows its metadata, and asks you to type RESTORE unless you passed --yes.

Afterwards

Everyone who signed in after the backup signs in again. Sessions the backup already held come back with it. Restart the DFIRe containers once the restore completes, because a restore clears the scheduled background jobs and a restart schedules them again. The backup list may show stale entries carried in by the restored database, so use Refresh. DFIRe writes an audit entry marking the boundary between restored and new activity.

Set DFIRE_DIRECT_DATABASE_URL when a pooler is in front of the database. A restore rebuilds the whole schema, and a transaction-mode pooler keeps server connections cached against the old one. With the direct URL set, DFIRe runs its startup migration pre-flight over the direct connection and a restart is safe. Without it, startup runs that pre-flight through the pooler, sees the same stale view, and the containers crash-loop until the pooler recycles. Managed poolers cannot be reset on demand.

To recover from that crash-loop, point DATABASE_URL at the direct endpoint, start once so it runs cleanly, then switch back. Waiting for the pooler to recycle its connections also works.

For a while after a restore, a pooled deployment may show relation "..." does not exist errors from connections still bound to the old schema. This is not data loss. The restore finished and the data is intact, and the pooler clears those connections as it recycles them.

The database connection

Poolers are fine for normal traffic. DFIRe is tested and supported against PgBouncer in transaction pooling and the equivalent on managed platforms, because ordinary reads and writes multiplex safely.

Backup and restore are the exception. pg_dump and pg_restore must talk to PostgreSQL directly. Run them through a pooler and a restore will corrupt the database permanently, because object IDs and session state from parallel streams get spread across different backend connections.

Never point backup or restore at a pooler. A pooled backup can appear to succeed while producing a file that will not restore, and a pooled restore corrupts the target silently. DFIRe does not try to detect poolers, because detection proved unreliable across managed providers. You declare the connection mode instead, and backup and restore stay disabled until you do.

Declaring the mode

In Settings → Backup & Recovery → DFIRe Server Connection Mode, pick one of two.

Direct connection suits a DATABASE_URL that already points at PostgreSQL. No second URL is needed. Before the mode activates you tick an acknowledgement confirming the host is a real PostgreSQL endpoint rather than a pooler, the checkbox exists because getting this wrong corrupts the database on restore, and nothing can verify the claim for you.

Connection pool suits a DATABASE_URL that points at a pooler. DFIRe then requires a separate direct URL, used only by pg_dump and pg_restore while the rest of the application keeps the pooled one:

postgres://username:password@db-host:5432/database_name

Save it, then choose Test Connection to confirm DFIRe can reach PostgreSQL through it and that the client version suits the server. The test does not try to classify the URL as direct or pooled. Providing one that bypasses the pooler is your responsibility.

Ask your provider if you are unsure which kind of endpoint you have. A pooler often gives itself away: pooler, pgbouncer or proxy in the hostname, a port such as 6432 or 25061, or documentation describing it as transaction mode or a shared connection. A direct endpoint usually sits on port 5432 and names the database instance.

Switching from direct to pool clears the acknowledgement. Switching from pool to direct clears the stored direct URL. Either way you configure and save the new mode before backups can run again.

PostgreSQL client versions

DFIRe ships client tools for PostgreSQL 16, 17 and 18, and picks the right one for your server. The client must be the same version as the server or newer. A newer client reads an older server, but not the reverse.

Extracting a backup for analysis

decrypt_backup turns a .enc file into a raw PostgreSQL custom-format dump, for forensic analysis, auditing, or loading into a separate instance.

# Using the running instance's SECRET_KEY
docker compose exec backend python manage.py decrypt_backup /path/to/backup.enc \
    -o /tmp/decrypted.dump

# Offline, with an explicit key
python manage.py decrypt_backup /path/to/backup.enc \
    -o /tmp/decrypted.dump --secret-key "your-secret-key-here"

# Metadata only
python manage.py decrypt_backup /path/to/backup.enc -o /dev/null --metadata-only

The result is a standard pg_dump --format=custom archive:

pg_restore --list /tmp/decrypted.dump
pg_restore -d analysis_db /tmp/decrypted.dump

The decrypted dump is as sensitive as the live database. It carries API keys, attachment encryption keys, and each enrolled user's authenticator seed and unused recovery codes. Those seeds are not protected by CREDENTIAL_ENCRYPTION_KEY, because verifying a code needs the raw value, so anyone who reads the dump can reproduce an enrolled user's second factor. Passwords stay hashed. The .enc file itself is still encrypted, so this applies to what you extract from it. Delete the output securely when you are done.

The two keys to keep

Store both somewhere separate from your backups. They usually sit in .env or in your platform's secrets configuration.

KeyWhat it protects
SECRET_KEYDerives every backup encryption key, and covers Django sessions and CSRF. Required to decrypt any backup file.
CREDENTIAL_ENCRYPTION_KEYEncrypts stored credentials such as integration API keys, webhook secrets and SSO configuration. Required to use integrations after a restore.

A password manager, a secrets manager such as Vault or AWS Secrets Manager, or a printed copy in a safe all work. Use more than one.

Never regenerate either key on a running installation. A new SECRET_KEY makes every existing backup unreadable. A new CREDENTIAL_ENCRYPTION_KEY makes stored credentials unreadable.

What you back up yourself

DFIRe's backup covers the database. Two things outside it are yours.

ComponentWhat to do
File storageBack it up the way your storage solution expects: S3 versioning and cross-region replication, NAS snapshots, or file server backups. DFIRe backups never contain the attachment files.
Secrets and configurationKeep a secure copy of SECRET_KEY, CREDENTIAL_ENCRYPTION_KEY and your database connection strings.

Attachment encryption keys live in the database, not in .env. Lose the database and the encrypted files on storage become permanently unreadable, however intact they are. Back up the database and the storage together, because a backup taken before an upload does not hold that file's key.

Exporting configuration

DFIRe can export configuration such as playbooks, evidence types, workflow steps, webhooks, roles and enrichment providers as JSON. It is a light way to copy settings between installations without touching case data. Find it under Settings → Global Settings, in the Data Portability section.

There are two modes. The plain export produces readable JSON and skips every secret, which suits version-controlled templates and moving non-sensitive settings around. The encrypted export carries the same payload including API keys, webhook tokens and SSO client secrets, encrypted under a password you choose. DFIRe does not store that password, so losing it loses the export.

Never send an encrypted export and its password together. Use separate channels, and rotate the affected credentials if you think either has been exposed.

Rebuilding on a new host

To bring DFIRe back from an encrypted backup file:

  1. Deploy a fresh instance with the original keys

    Set SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY to the values the backup was made with. You need both, one to read the backup and one to read the credentials inside it.

  2. Point storage at the same place

    Configure the storage targets in Settings so they reach the original location, or restore your storage files there first.

  3. Declare the connection mode

    Backup and restore stay disabled until you do. See The database connection.

  4. Upload the backup

    DFIRe decrypts and checks every chunk before accepting the file, so a wrong key or a damaged file is rejected here rather than mid-restore.

  5. Restore, then check

    Confirm that cases, evidence, attachments and user accounts are all reachable.

With command-line access, put the file in the Backup role's storage location or upload it, then:

docker compose exec backend python manage.py list_backups
docker compose exec backend python manage.py restore_backup <backup-uuid> --yes
← Storage Audit log →