Docker Compose deployment
Deploy DFIRe from the release-pinned Docker Compose bundle, without the installer.
The installer downloads this same bundle and generates the secrets for you. It never rewrites the Compose files. Deploy by hand when you want to control the configuration yourself, or when you work in an air-gapped network.
Requirements
- Docker Engine 24.0 or later, with the Compose plugin 2.24.4 or later.
curl,tar,openssl, and eithersha256sumorshasum.- PostgreSQL 16 or later. Use a server you manage.
- Production starting recommendation: 8 vCPU, 16 GB RAM and a 100 GB SSD system disk.
- Additional evidence and backup storage, sized to your caseload and retention requirements.
- Small evaluation: 2 vCPU, 4 GB RAM and 20 GB of free SSD space.
These sizes allow headroom for interactive work and background jobs. They are planning recommendations, not measured capacity limits. Monitor memory, disk usage and job queues as your workload grows.
Use an external database in production. The bundle can run PostgreSQL in a container, but that database lives in a Docker volume with no backup or availability plan of its own. Treat it as an evaluation convenience.
What runs
The bundle starts five services, or six with the bundled database. Three of them run the same backend image with different commands.
| Container | Image | Purpose |
|---|---|---|
| backend | dfireadmin/dfire-backend | The API and WebSockets on port 8000, and attachment transfers on port 8001 |
| qcluster | dfireadmin/dfire-backend | Background tasks: IOC enrichment, webhook deliveries, backups, scheduled work |
| slack-socket | dfireadmin/dfire-backend | The Slack listener, idle unless you connect Slack |
| frontend | dfireadmin/dfire-frontend | The web interface, and the nginx that routes every path to the backend |
| redis | redis:8-alpine | Cache and message broker |
| db (optional) | postgres:16-alpine | The bundled evaluation database |
Only the frontend publishes a port. Everything else talks over an internal Docker network.
Container logs
Every service in the bundle caps its container log at 50 MB per file and keeps 10 files. A service never holds more than 500 MB of logs, and the whole stack stays under 3 GB. Without a cap, Docker keeps every line a container ever wrote. A busy install then fills its disk.
docker compose logs reads the retained container logs. The in-app support package collects separate application logs for backend, qcluster and slack-socket: the current file and most recent rotation for each service. Use the host support script for container diagnostics, including frontend, Redis and PostgreSQL logs.
To keep more history, add a compose.override.yaml next to the bundle and raise max-file for the services you care about. Do not edit the bundle itself.
services:
backend:
logging:
options:
max-file: "30"
Append the override to COMPOSE_FILE in .env, after your database overlay. For an external database:
COMPOSE_FILE=compose.yaml:compose.external-db.yaml:compose.override.yaml
Run docker compose config to check the merged policy before applying it. Keep deployments with custom overrides under manual management. The installer selects only the published base file and database overlay.
Docker applies the policy when it creates a container. A deployment updated with docker compose up -d gets it on the next update. A restart alone does not apply it.
Deploy from a release bundle
DFIRe publishes every release as an immutable set of Compose files at a versioned dfire.fi URL. The bundle pins each container image to the digest tested for that release.
-
Create the installation directory
sudo install -d -o "$(id -un)" -g "$(id -gn)" /opt/dfire cd /opt/dfire -
Download and verify the release
curl -fsSLO https://dfire.fi/release/x.x.x/dfire-x.x.x.tar.gz curl -fsSLO https://dfire.fi/release/x.x.x/SHA256SUMS grep ' dfire-x.x.x.tar.gz$' SHA256SUMS | sha256sum -c - tar -xzf dfire-x.x.x.tar.gzThe same directory, dfire.fi/release/x.x.x/, also holds the loose Compose files, the environment example, the README, the checksums and the supply-chain evidence.
-
Configure the environment
cp .env-example .env chmod 600 .env $EDITOR .envThe file documents every value. The section below covers the settings that decide the shape of the deployment.
-
Validate, pull and start
docker compose config docker compose pull docker compose up -d --wait --wait-timeout 600Compose reads the base file and the database overlay from
COMPOSE_FILEin.env. -
Check both routes
curl -f http://127.0.0.1:8080/health curl -f -H 'Host: localhost' http://127.0.0.1:8080/api/health/The first checks the frontend. The second checks that the backend reached the database.
Keep the project identity stable. Use the same COMPOSE_PROJECT_NAME and the same installation directory on every update. Compose then recreates only the changed services and keeps the project's named volumes.
A deployment built from these published files can later switch to the installer. Run ./install.sh --upgrade with the current installer from the installation directory. It checks the release files, the environment topology, the running Compose project, the version and the volumes before it takes over. A customized Compose deployment is never overwritten, and stays manual.
The environment file
The bundle's .env-example is the reference for that release. For every value it states whether it is required and which containers read it. It also marks the secrets, and the values that must never change.
Five settings decide the shape of the deployment.
COMPOSE_FILE=compose.yaml:compose.external-db.yamlselects your own PostgreSQL. Use this in production.COMPOSE_FILE=compose.yaml:compose.internal-db.yamlselects the bundled evaluation database instead.COMPOSE_PATH_SEPARATOR=:must be present. Compose separates the files inCOMPOSE_FILEwith;on Windows and:elsewhere. Without this line, Compose on Windows reads the whole value as one filename and reports a missing file.COMPOSE_PROJECT_NAMEmust stay unchanged across upgrades, so Compose keeps using the same named volumes.FRONTEND_BINDsets the published port. Use127.0.0.1:8080:80for a reverse proxy on the same host, or0.0.0.0:8080:80for a proxy elsewhere.
DFIRE_Q_WORKERS sets how many background tasks the qcluster container runs at once. The default is 2. Start with 4 on an installation that receives Dashboard data through Dashboard Sync. See Task workers.
GUNICORN_WORKERS sets how many file transfers the backend serves at once. The default is 2. Each Encrypted Storage download, each SMB or SFTP Direct Storage download or upload chunk, each backup download, and each report PDF holds one worker until it is complete. A large download can hold its worker for hours, and other transfers wait for a free worker. Set it to the number of downloads you expect at the same time, plus at least one for uploads. See Storage: Downloads.
Do not add image overrides for the backend or the frontend. The bundle already pins every image to the digest tested for the release.
Back up SECRET_KEY and CREDENTIAL_ENCRYPTION_KEY. Neither may change after installation. SECRET_KEY protects backup archives. CREDENTIAL_ENCRYPTION_KEY protects stored credentials, including webhook secrets and license keys. Keep both original keys to restore a working installation.
Other container platforms
Not a supported deployment model. DFIRe tests and supports one thing: Docker Compose on a Linux host. It does run on managed container platforms such as Amazon ECS, and customers run it there today. DFIRe still does not test those platforms and cannot support a deployment on one. Treat the notes below as a starting point you own, not as a procedure.
The bundle's .env-example describes the environment the backend, qcluster and Slack listener each need, and marks the few values Compose supplies for them. On another platform you take on the networking, secret injection, persistent storage, service ordering, migrations and reverse proxy yourself.
These details catch people out, because Compose handles them silently.
The backend and the worker share a filesystem
A chunked upload does not stream straight to its final storage. The backend encrypts each chunk as it arrives and spools it to a file. The background worker later reads that file and moves it to the storage target. Both containers need the same directory, on storage that lasts the life of the upload.
Compose gives them one volume, so the question never comes up. Separate tasks with their own ephemeral disks break every upload, because the worker cannot find what the backend wrote. DFIRe always uses temp_uploads inside the media volume. No setting changes that path.
Container logs need a cap
The bundle's Compose files cap every container log at 50 MB per file and 10 files. Another platform does not read those files. Set the equivalent yourself: a log driver with a size limit, or a log group with a retention period. Unbounded logs are the first thing that fills the disk.
The backend listens on two ports
Port 8000 serves the API and WebSockets. Port 8001 serves attachment transfers, which are long-running and do not belong on the same server. The frontend container's nginx splits the traffic between them. Anything you put in front of the backend has to do the same, or attachment downloads and direct uploads reach the wrong port.
IPv6 errors in the frontend log
A frontend container with no IPv6 route can log these lines again and again. The address differs per system.
connect() to [2600:f0f0::1]:8000 failed (101: Network unreachable) while connecting to upstream
upstream server temporarily disabled while connecting to upstream
The frontend container's nginx uses every address of the name backend. Amazon ECS Service Connect publishes an IPv4 and an IPv6 address for that name. Requests still succeed over IPv4, so these lines alone do not mean an outage.
To stop these errors, connect the frontend to the backend over IPv4 only:
- Set
DFIRE_BACKEND_IPV4_ONLY=truein the environment of the frontend container. On Amazon ECS, add it to the frontend container in the task definition:"environment": [ { "name": "DFIRE_BACKEND_IPV4_ONLY", "value": "true" } ] - Start a new frontend container. On Amazon ECS, register the task definition and update the service.
- Read the frontend log and make sure that the errors stop.
To check the configuration, run this command in the frontend container:
grep ':800[01];' /etc/nginx/conf.d/default.conf
Each line now starts with an IPv4 address of backend:
server 10.0.1.5:8000; # server backend:8000;
server 10.0.1.5:8001; # server backend:8001;
In a Compose deployment, a value in .env does nothing, because the bundle passes no environment to the frontend. Set the variable in compose.override.yaml, and add that file to COMPOSE_FILE as Container logs shows:
services:
frontend:
environment:
DFIRE_BACKEND_IPV4_ONLY: "true"
The container reads the addresses of backend each time it starts. If the backend address changes, restart the frontend.
The setting changes only the two upstream lines of the configuration in the image. A custom configuration with other upstream lines stays as it is. It needs the root user and a writable /etc/nginx/conf.d. Without them, the container logs a warning ending in keeping the configured upstream and starts with the normal configuration.
HTTPS
Serve DFIRe over HTTPS in production. Terminate TLS on your own reverse proxy or load balancer. The bundle does not install or change any proxy or certificate service on the host.
Point an existing proxy at DFIRe
Set these three values in .env:
AUTH_COOKIE_SECURE=True
TRUST_PROXY_HEADERS=true
FRONTEND_BIND=0.0.0.0:8080:80
Use FRONTEND_BIND=127.0.0.1:8080:80 instead when the proxy runs on the same host.
Your proxy must forward to port 8080 on the DFIRe host, set X-Forwarded-Proto: https, and support WebSocket upgrades and long-running upload and download requests.
DFIRe records the original client address in audit entries and sessions, and uses it for rate limits. It trusts the address only when the proxy connects from a loopback or private address (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16). It reads the address from the first of these that applies:
X-Real-IP, when it names the same address as the lastX-Forwarded-Forentry.CF-Connecting-IP, when it names that address and the request has noX-Real-IP.- The last
X-Forwarded-Forentry, when the request has neither of those headers. DFIRe ignores a port after the address. X-Real-IP, when the request has noX-Forwarded-For.
Otherwise, DFIRe records the address of the proxy.
We recommend that the proxy replaces X-Real-IP with the address that connected to it and adds the same address to the end of X-Forwarded-For. The nginx example below does both. In Caddy, add header_up X-Real-IP {remote_host} to the reverse_proxy block. Cloudflare Tunnel, AWS Application Load Balancer and Azure Application Gateway need no change.
When Cloudflare proxies requests to your own reverse proxy instead of through a tunnel, the requests arrive from Cloudflare's public addresses. Configure that proxy to restore the visitor address from CF-Connecting-IP, trusting only Cloudflare's published address ranges, before it sets X-Real-IP. Otherwise, DFIRe records Cloudflare's address.
Allow port 8080 only from your proxy. With TRUST_PROXY_HEADERS=true, anyone who can reach the port from a private network can choose the client address that DFIRe records. Bind the port to 127.0.0.1 when the proxy runs on the same host. Otherwise, block the port with a firewall for every address except the proxy. The proxy must also replace or extend X-Forwarded-For. A proxy that passes the client's header on unchanged lets the client choose the recorded address. AWS Application Load Balancer does this in its preserve mode.
Run nginx on the host
Install nginx and certbot, get a certificate, then use the configuration below.
sudo apt install nginx certbot python3-certbot-nginx
sudo certbot certonly --standalone -d dfire.example.com
server {
listen 80;
server_name dfire.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name dfire.example.com;
ssl_certificate /etc/letsencrypt/live/dfire.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dfire.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 4096M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
}
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
}
Keep the timeouts long. Evidence uploads run for a long time, and WebSocket connections stay open. A short timeout drops both mid-transfer. This includes a limit on the total time of one request: an 8 MB chunk can take minutes on a slow connection. Set client_max_body_size to at least 4096M, which is what the frontend container allows.
One proxy rule covers every path. The frontend container routes /api/, /ws/, /taxii2/, /misp-feed/, /mcp/, /oidc/ and /admin/ to the backend on its own. TAXII and the MISP feed need no separate proxy configuration.
Bundled database for evaluation
Select the internal overlay in .env, then start the stack as usual.
COMPOSE_PATH_SEPARATOR=:
COMPOSE_FILE=compose.yaml:compose.internal-db.yaml
POSTGRES_DB=dfire
POSTGRES_USER=dfire
POSTGRES_PASSWORD=replace-with-a-strong-password
Evaluation only. This database stores everything in a Docker named volume. Production needs an external PostgreSQL service with its own backup and recovery plan.
Licensing
A new installation runs for 90 days without a license. After that it needs one.
Check that the host can reach the license server before you deploy:
curl https://license.dfire.fi/api/v1/
A reply of {"detail":"Authentication credentials were not provided."} means the server is reachable. DFIRe validates the license itself once it starts.
Setting up a new installation also reads which version of the End User License Agreement is published, so the host needs to reach dfire.fi once:
curl -I https://dfire.fi/eula
The setup screen then shows that version and links to it, and you accept it there. See accepting the agreement offline if the host has no internet access at all.
Air-gapped installations use an offline license instead. Buy a license from the pricing page, then write to contact@dfire.fi to convert it. Conversion is free.
Air-gapped deployment
Prepare the bundle and its images on a connected host, then move both through your approved media process. The connected Docker daemon must run the same operating system and CPU architecture as the isolated target. The pull fetches only the platform that daemon selects, and the save exports only what it finds locally.
-
Prepare and pull on a connected system
tar -xzf dfire-x.x.x.tar.gz cp .env-example .env $EDITOR .env docker compose config docker compose pull -
Export every image the bundle selects
docker image save -o dfire-images-x.x.x.tar $(docker compose config --images) -
Transfer and load
Move the verified release archive,
SHA256SUMS, the configured.envand the image archive. Protect.envas a secret.docker image load -i dfire-images-x.x.x.tar tar -xzf dfire-x.x.x.tar.gz chmod 600 .env docker compose up --pull never -d --wait --wait-timeout 600--pull nevermakes a missing or corrupted image fail locally instead of reaching for the registry. Rehearse the whole save, load and start sequence on an equivalent disconnected host before you depend on it. -
Install an offline license
Arrange it with contact@dfire.fi before you disconnect the host.
-
Accept the End User License Agreement
The setup screen reads the agreement's version from dfire.fi/eula and an isolated host cannot reach it, so accept the agreement from the command line instead. Read it first, on a machine that has internet access.
docker compose exec backend python manage.py accept_eulaThis prints the version, its publication date and where to read it, and changes nothing. When you are ready:
docker compose exec -it backend python manage.py accept_eula --acceptThe command shows the version again and asks you to confirm it, because it reads the version fresh each time. A host that cannot reach the site gets the agreement the release shipped with instead. The command says so, and warns that a newer version may be published. Answer
yand DFIRe records it against the installation, and the setup screen moves on. Acceptance is recorded for theadminaccount; name a different superuser with--user.
Updates repeat this process with the next release bundle. Slack integration needs an outbound connection and does not work in an isolated network.
Updating
Back up the database, the file storage and .env first. Then run ./install.sh --upgrade with the current installer from the installation directory. To update by hand instead, replace the bundle and run the same three commands you used to deploy:
docker compose config
docker compose pull
docker compose up -d --wait --wait-timeout 600
Do not run docker compose down as part of a routine update. See Updating DFIRe for adoption of an existing layout, manual bundle replacement and recovery.
Admin tasks
Run these from the installation directory.
# Create an administrator account
docker compose exec backend python manage.py createsuperuser
# Open a Django shell
docker compose exec backend python manage.py shell
# Apply database migrations by hand
docker compose exec backend python manage.py migrate
Migrations run automatically when the backend container starts, so the last command is only for recovery.