A production n8n Docker Compose stack needs Postgres instead of SQLite, a pinned n8n version, HTTPS through a reverse proxy, and N8N_WEBHOOK_URL plus N8N_PROXY_HOPS set. This guide gives one compose file with Postgres 18, Redis queue mode with a worker, external task runners and Caddy auto-HTTPS, assembled from n8n's official examples, plus the env-var table, a nightly backup job and fixes for the common failures.
To run n8n with Docker Compose in production, put n8n, Postgres, Redis, a worker, two task runner containers and Caddy in one compose file, pin the n8n version, and set N8N_WEBHOOK_URL and N8N_PROXY_HOPS so webhooks work behind the proxy. The file below does exactly that, and Caddy fetches and renews the HTTPS certificate on its own.
Commands and config checked October 2026 against n8n's Docker Compose install guide, Hetzner guide (the Caddy setup), withPostgresAndWorker example, queue mode, task runner and backup docs, and Caddy's reverse_proxy docs. The current stable n8n release on those pages was 2.42.4.
Existing guides tend to stop at a single n8n container on SQLite, skip worker mode, or leave HTTPS as an exercise. This one is a single copy-paste stack. It is not the only valid layout: n8n's own server guide uses Traefik, and the one-line installer is quicker for a laptop. If you are still deciding whether to self-host at all, read our n8n Cloud vs self-hosted guide first, and for ideas on what to run once it is up, our n8n hub with workflow templates.
What you will have at the end
- 01Caddy
Terminates HTTPS on 443, renews the certificate, forwards to n8n:5678.
- 02n8n main
Serves the editor, receives webhooks and schedules, creates an execution.
- 03Redis
Holds the queue of pending execution IDs.
- 04n8n worker
Picks up the job, runs the workflow, sends Code nodes to its task runner.
- 05Postgres
Stores workflows, credentials, executions and binary data.
Process flow from n8n's queue mode docs, checked October 2026.
Seven containers: Caddy, Postgres, Redis, the n8n main instance, its task runner, one worker and the worker's task runner. Task runners are the isolation layer for Code node scripts; n8n's task runner docs say to always use external mode in production and that in queue mode each worker needs its own runner sidecar.
Prerequisites
- A Linux server with a public IP and SSH key login
- Docker Engine and the Compose v2 plugin (docker compose version prints a version)
- A subdomain such as n8n.example.com with an A record pointing at the server
- Ports 80 and 443 open to the internet, SSH open to you, nothing else
- At least 4 GB RAM and 2 vCPUs for this seven-container stack (our sizing, not n8n's)
- A password manager or vault for four generated secrets
- Somewhere off the server to copy nightly backups to
On sizing: n8n's Hetzner guide says its smallest CPX11 type is enough for a single n8n container at most usage levels, and asks for 4 GB RAM and 2 vCPUs once you add the n8n Assistant sandbox. A worker, Redis and two runners add overhead, so we would not start this stack below 4 GB. What your server costs is covered in our n8n pricing guide.
Self-host n8n with Docker: the steps
Step 1: install Docker and open the firewall
Install Docker Engine and the Compose plugin from Docker's install docs for your distribution, then allow SSH, HTTP and HTTPS. Allow SSH before enabling the firewall or you lock yourself out.
docker --version docker compose version sudo ufw allow OpenSSH sudo ufw allow 80 sudo ufw allow 443 sudo ufw enable
Expected result: both version commands print a version, and sudo ufw status lists 22, 80 and 443.
Step 2: create the folder and secrets
sudo mkdir -p /opt/n8n && sudo chown $USER /opt/n8n && cd /opt/n8n # Run four times; paste each value into .env openssl rand -hex 32
# .env (chmod 600 .env, and never commit it) N8N_VERSION=2.42.4 DOMAIN=n8n.example.com GENERIC_TIMEZONE=Europe/Berlin POSTGRES_USER=pgadmin POSTGRES_PASSWORD=paste-secret-1 POSTGRES_DB=n8n POSTGRES_NON_ROOT_USER=n8n POSTGRES_NON_ROOT_PASSWORD=paste-secret-2 ENCRYPTION_KEY=paste-secret-3 RUNNERS_AUTH_TOKEN=paste-secret-4
Store ENCRYPTION_KEY in your password manager as well as on the server. n8n's backup guide is explicit that without the encryption key, a restored database's credentials cannot be decrypted. Check the current stable version on n8n's releases page before you pin one.
Step 3: create the Caddyfile
n8n.example.com {
reverse_proxy n8n:5678 {
flush_interval -1
}
}This is the Caddyfile from n8n's Hetzner guide, with your domain in place of the placeholder. flush_interval -1 streams responses straight through. Caddy's docs say it serves public domain names over HTTPS with certificates from Let's Encrypt or ZeroSSL, redirects HTTP to HTTPS, and sets the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers by default, which is what n8n's reverse proxy page asks the last proxy to send.
Step 4: create init-data.sh
From n8n's withPostgresAndWorker example. It runs once, on the first Postgres start, and creates the non-root user n8n connects with.
#!/bin/bash
set -e;
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
echo "SETUP INFO: No Environment variables given!"
fichmod +x init-data.sh
Step 5: create compose.yaml
This merges n8n's withPostgresAndWorker file with the Caddy service and the domain, proxy and binary-data settings. Changes from the official example: Caddy in front, n8n's port not published to the host, N8N_HOST, N8N_WEBHOOK_URL, N8N_PROXY_HOPS and timezone added, and binary data stored in the database because queue mode does not support filesystem storage.
volumes:
caddy_data:
caddy_config:
db_storage:
n8n_storage:
redis_storage:
x-shared: &shared
restart: always
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
- N8N_ENCRYPTION_KEY=${ENCRYPTION_KEY}
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_DEFAULT_BINARY_DATA_MODE=database
- N8N_HOST=${DOMAIN}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_WEBHOOK_URL=https://${DOMAIN}/
- N8N_PROXY_HOPS=1
- NODE_ENV=production
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
volumes:
- n8n_storage:/home/node/.n8n
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
x-runner: &runner
restart: always
image: n8nio/runners:${N8N_VERSION}
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- caddy_data:/data
- caddy_config:/config
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- n8n
postgres:
image: postgres:18
restart: always
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
- PGDATA=/var/lib/postgresql/data
volumes:
- db_storage:/var/lib/postgresql/data
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: always
volumes:
- redis_storage:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10
n8n:
<<: *shared
n8n-runner:
<<: *runner
environment:
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
depends_on:
- n8n
n8n-worker:
<<: *shared
command: worker
depends_on:
- n8n
n8n-worker-runner:
<<: *runner
environment:
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n-worker:5679
depends_on:
- n8n-workerStep 6: start the stack
docker compose up -d docker compose ps docker compose logs -f n8n
Expected result: postgres and redis show healthy, the other five show running, and the n8n log reports that the editor is accessible. The first start takes a minute while n8n migrates the empty database and Caddy requests the certificate.
Step 7: verify HTTPS, the worker and the runners
curl -sf https://n8n.example.com/healthz && echo OK docker compose logs n8n-worker | tail -20 docker compose logs n8n-worker-runner | tail -20
Expected result: OK, a worker log that shows it connected and is waiting for jobs, and a runner log without authentication errors. Then build a two-node test workflow with a Code node and run it from the editor: with manual executions offloaded to workers, a successful run proves the worker, its runner and Redis are all wired up.
Step 8: claim the owner account straight away
Open your domain and create the owner account immediately. Until you do, anyone who reaches the URL sees the setup screen, and n8n's backup docs note that the first person to complete owner setup becomes the owner. Then turn on two-factor authentication in your personal settings.
- 1Docker and firewall
Compose v2 installed; 22, 80 and 443 open.
- 2Folder and .env
Four secrets generated; encryption key saved off the server.
- 3Caddyfile
Your domain, reverse_proxy to n8n:5678.
- 4init-data.sh
Creates the non-root Postgres user on first start.
- 5compose.yaml
Seven services, version pinned.
- 6Start and verify
Healthy containers, /healthz returns OK over HTTPS.
- 7Owner account and 2FA
Before anyone else finds the URL.
- 8Backups
Nightly job below, copied off the server.
The environment variables that matter
| Variable | What it does | Why it matters |
|---|---|---|
| N8N_VERSION | Pins n8n and the runners image to one release, e.g. 2.42.4 | Unpinned images upgrade on every pull, including across breaking changes |
| DOMAIN | Your n8n hostname, e.g. n8n.example.com | Feeds N8N_HOST and N8N_WEBHOOK_URL; must match the Caddyfile |
| ENCRYPTION_KEY | Encrypts saved credentials; shared by main and worker | Lose it and every stored credential is unreadable |
| POSTGRES_USER / POSTGRES_PASSWORD | Postgres superuser, used only for setup and backups | Keep out of the n8n containers |
| POSTGRES_NON_ROOT_USER / _PASSWORD | The user n8n connects as, created by init-data.sh | Least privilege for the app |
| RUNNERS_AUTH_TOKEN | Shared secret between n8n and the task runner containers | Code nodes fail if it does not match |
| GENERIC_TIMEZONE | Timezone for schedules; defaults to America/New_York | Schedules fire at the wrong hour otherwise |
| N8N_WEBHOOK_URL | Public base URL for webhooks behind the proxy | Replaces WEBHOOK_URL, deprecated from n8n 2.35.0 |
| N8N_PROXY_HOPS=1 | Tells n8n one proxy (Caddy) sits in front | Client IPs and protocol detection break without it |
| N8N_DEFAULT_BINARY_DATA_MODE=database | Stores files in Postgres so main and worker both see them | Queue mode does not support filesystem binary storage |
References: n8n's deployment, endpoint and binary data variable pages, checked October 2026. Database-mode binary storage caps a single file at 512 MiB by default.
The backup job
n8n's backup guide says a complete backup is the Postgres database plus the .n8n folder, which in Docker lives in the n8n storage volume and holds the encryption key config. The CLI export commands alone miss users, execution history, variables and settings. This script takes all three pieces every night:
#!/usr/bin/env bash # /opt/n8n/backup.sh - nightly n8n backup set -euo pipefail cd /opt/n8n set -a; . ./.env; set +a mkdir -p backups STAMP=$(date +%F) # 1. Postgres dump docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ | gzip > "backups/n8n-db-$STAMP.sql.gz" # 2. The .n8n folder (check the volume name with: docker volume ls) docker run --rm -v n8n_n8n_storage:/data:ro -v "$PWD/backups":/backup alpine \ tar czf "/backup/n8n-storage-$STAMP.tar.gz" -C /data . # 3. The .env file, which holds the encryption key in this setup cp .env "backups/env-$STAMP" chmod 600 backups/* # 4. Keep 14 days locally; copy off the server with your own tool find backups -type f -mtime +14 -delete
chmod +x /opt/n8n/backup.sh crontab -e # add this line: run at 03:15 every night 15 3 * * * /opt/n8n/backup.sh >> /opt/n8n/backup.log 2>&1
Copy the backups folder off the server with whatever you already use; a backup on the same disk does not survive the disk. Then test a restore on a spare server: stop n8n, restore the volume and the database, restore the .env file, start the stack, and log in. Your error alerts belong in n8n too; our n8n error handling guide shows how to set an error workflow that messages you when something fails.
Updating the stack
./backup.sh # edit N8N_VERSION in .env, then: docker compose pull docker compose up -d
Pinning n8n and the runners image to the same variable matters: n8n's task runner docs say the n8nio/runners version must match n8nio/n8n. n8n releases a minor version most weeks and recommends updating at least monthly. n8n 3.0 is scheduled for October 2026 and removes legacy nodes such as Function and Cron; check the 3.0 migration report in Settings first, and see our n8n 3.0 upgrade guide. Postgres is different: moving an existing volume to a new major version fails with database files are incompatible with server, so follow PostgreSQL's upgrade guide rather than bumping the tag.
Troubleshooting
- No certificate, browser shows a TLS error. The A record does not point at this server yet, or port 80 or 443 is closed. Check with
docker compose logs caddy. - Webhook URLs show localhost or port 5678.
N8N_WEBHOOK_URLis missing or wrong. Set it to your public HTTPS address and rundocker compose up -d. - Database is empty after a restart. The
PGDATAline is missing; see the warning above. - Code node fails or hangs.
RUNNERS_AUTH_TOKENdiffers between containers, or the runners image version does not match n8n. - Credentials cannot be decrypted after a move. The new instance has a different
ENCRYPTION_KEY. Restore the original.env. - Workflows queue up and run slowly. One worker runs 10 jobs at once by default. Add workers, each with its own runner sidecar, or raise concurrency with
worker --concurrency; n8n recommends 5 or more.
Once it is running, the useful next step is building real workflows on it. If you want to turn those workflows into a product that customers pay for, our AI SaaS Builder program covers building and charging for it.
n8n Docker Compose: FAQ
What is the best Docker Compose file for n8n?
For production, use one that runs n8n against Postgres rather than the default SQLite, pins the n8n version, puts a reverse proxy with automatic HTTPS in front, and sets N8N_WEBHOOK_URL and N8N_PROXY_HOPS. Add Redis, a worker and task runner containers when you want queue mode. The file in this guide combines n8n's official withPostgresAndWorker example with the Caddy setup from n8n's Hetzner guide.
Does n8n Docker Compose need Postgres?
No, n8n runs on SQLite by default, and n8n's docs call SQLite fine for trying things out. They recommend Postgres for a production instance with more than a handful of users or workflows running around the clock, and running queue mode on SQLite is not recommended. n8n supports PostgreSQL 16, 17 and 18. Switching later starts with an empty database, so decide before you build.
Is queue mode available on the free n8n Community Edition?
Yes. n8n's edition comparison says queue mode is included in the Community Edition, while multi-main, which runs several main instances for high availability, needs a paid license. Queue mode needs Redis, Postgres, the same encryption key on every instance, and no filesystem binary data storage. Workers default to running 10 jobs at once; n8n recommends a concurrency of 5 or more.
Why use Caddy instead of Traefik for n8n?
Either works; n8n documents both. Its generic Docker Compose server guide uses Traefik, and its Hetzner guide uses Caddy. Caddy gets and renews Let's Encrypt certificates and redirects HTTP to HTTPS by default, and it sets the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers n8n expects behind a proxy, so the config is a four-line Caddyfile.
How do I update n8n running in Docker Compose?
Back up first, read the release notes for breaking changes, then change N8N_VERSION in .env and run docker compose pull followed by docker compose up -d. The n8n and n8nio/runners images must stay on the same version, which pinning both to one variable handles. n8n releases a minor version most weeks and recommends updating at least monthly.
What do I need to back up for a Docker n8n install?
Two things, per n8n's backup guide: the Postgres database and the .n8n folder, which in Docker lives in the n8n storage volume and holds the encryption key config. Keep your .env file too, since this setup sets the encryption key there. Without that key, credentials in a restored database cannot be decrypted. Test a restore on a spare server before you rely on it.
Server's up. Now build on it.
AI SaaS Builder, included in All Access, covers n8n automations, AI agents and shipping a paid AI product, with the other three programs, live coaching and the private community in one subscription.
Setting up your first n8n server?
Grab the free Creator Starter Kit, then browse our n8n hub for workflows worth running on your new instance.