How to Self-Host n8n with Docker Compose in Queue Mode (2026)
This guide is for anyone who has outgrown the single n8n container from a tutorial and wants a production-shaped install on one Linux host: Postgres for state, Redis as the job queue, a separate webhook processor, two workers each with its own task runner sidecar, Caddy terminating TLS, plus the backup, upgrade and patching routine that keeps it alive. I tested every command here on 20 October 2026 with n8n 2.43.8 (the current stable release), n8nio/runners 2.43.8, Postgres 17.11, Redis 8.8.3 and Caddy 2.11.7, on Docker Engine 28 with Compose v2. You need a Linux host with Docker and the Compose plugin, a DNS name pointing at it, and ports 80 and 443 open. If you are still deciding whether to run n8n yourself at all, read the self-hosting vs SaaS post first: for small volumes n8n Cloud is often cheaper than your time.
Step 1: Pick a patched n8n version and pin it
Do not run :latest or even :stable. n8n ships patch releases several times a week, and security fixes land in them. The repository has published 211 GitHub security advisories in 2026 so far, 19 rated critical, and the worst of the last year were serious: CVE-2026-21858 let an unauthenticated attacker read files through form workflows on versions 1.65.0 to before 1.121.0, CVE-2025-68613 (expression evaluation RCE, fixed in 1.120.4, 1.121.1 and 1.122.0) is on CISA's known-exploited list since 11 March 2026, and CVE-2026-25049 let any workflow editor run system commands before 1.123.17 and 2.5.2. The 14 October 2026 batch of advisories is fixed in 2.43.5, 2.44.1 and 1.123.85, so anything older than those is behind.
Check the current stable tag and the latest advisories before you write a version into a file:
# Current stable release (GitHub CLI, authenticated)
gh api repos/n8n-io/n8n/releases/latest --jq .tag_name
# n8n@2.43.8
# The ten most recent advisories and the versions that fix them
gh api 'repos/n8n-io/n8n/security-advisories?per_page=10&sort=published&direction=desc' \
--jq '.[] | [.published_at[0:10], .severity, .summary] | @tsv'
Use the 2.x line. The 1.x line still receives backports (1.123.85 came out on 14 October), but the 2.0 breaking changes are where the security defaults improved: Code nodes run in task runners, N8N_BLOCK_ENV_ACCESS_IN_NODE defaults to true, the Execute Command and Local File Trigger nodes are disabled, and the main image no longer contains the external runner, which now ships as n8nio/runners.
Step 2: Create the project directory and secrets
Everything lives in one directory with a .env file that only root can read. The encryption key is the important one: n8n encrypts every stored credential with it, and every n8n process (main, webhook, workers) needs the same value. If you do not set it, n8n generates one on first start and writes it into the data volume, which works until you rebuild the volume or add a worker that never saw it.
sudo mkdir -p /opt/n8n && cd /opt/n8n
sudo tee .env >/dev/null <<EOF
N8N_DOMAIN=n8n.example.com
POSTGRES_PASSWORD=$(openssl rand -hex 24)
REDIS_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)
EOF
sudo chmod 600 .env
# Copy the encryption key somewhere that is not this server, now.
sudo grep N8N_ENCRYPTION_KEY .env
Store that key in your password manager or secrets store before you go further. A database backup without it is a backup of unreadable credentials.
Step 3: Write the Compose file for queue mode
In queue mode the main process serves the editor and API and puts executions on a Redis queue; workers pull jobs and run them. A webhook processor receives production webhook calls so traffic spikes do not land on the editor. The queue mode docs list the moving parts; the task runner docs add the rule that matters most for Compose: each worker needs its own runners sidecar, at exactly the same version as n8n. That is why the file below defines named worker and runner pairs instead of using replicas, which would let a runner attach to whichever worker DNS returned.
Save this as /opt/n8n/compose.yaml:
# compose.yaml: n8n 2.43.8 in queue mode
# main (editor + API) | webhook processor | 2 workers, each with a runners sidecar
# Postgres 17 for state, Redis 8 for the Bull queue, Caddy for TLS.
x-n8n-image: &n8n-image n8nio/n8n:2.43.8 # pin; never :latest or :stable
x-runners-image: &runners-image n8nio/runners:2.43.8 # must equal the n8n version
x-n8n-env: &n8n-env
# Database
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
# Queue
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}
QUEUE_HEALTH_CHECK_ACTIVE: "true"
OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: "true"
# Same key on every n8n process, or workers cannot decrypt credentials
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
# Public URLs (behind one reverse proxy)
N8N_HOST: ${N8N_DOMAIN}
N8N_PROTOCOL: https
N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}/
N8N_WEBHOOK_URL: https://${N8N_DOMAIN}/
N8N_PROXY_HOPS: "1"
# Task runners: Code node runs in the sidecar, not in n8n
N8N_RUNNERS_MODE: external
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
# Binary data: filesystem mode is not supported in queue mode
N8N_DEFAULT_BINARY_DATA_MODE: database
# Housekeeping
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "168" # hours; execution data holds API responses
N8N_DIAGNOSTICS_ENABLED: "false"
GENERIC_TIMEZONE: Etc/UTC
TZ: Etc/UTC
services:
postgres:
image: postgres:17.11-alpine
restart: unless-stopped
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:8.8.3-alpine
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}",
"--appendonly", "yes", "--maxmemory-policy", "noeviction"]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 10
n8n-main:
image: *n8n-image
restart: unless-stopped
environment:
<<: *n8n-env
N8N_DISABLE_PRODUCTION_MAIN_PROCESS: "true" # production webhooks go to n8n-webhook
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
n8n-webhook:
image: *n8n-image
restart: unless-stopped
command: webhook
environment: *n8n-env
depends_on:
n8n-main: { condition: service_started }
n8n-worker-1: &worker
image: *n8n-image
restart: unless-stopped
command: worker --concurrency=10
environment:
<<: *n8n-env
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0 # let the sidecar reach port 5679
depends_on:
n8n-main: { condition: service_started }
runners-1: &runners
image: *runners-image
restart: unless-stopped
environment:
N8N_RUNNERS_TASK_BROKER_URI: http://n8n-worker-1:5679
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
depends_on: [n8n-worker-1]
n8n-worker-2:
<<: *worker
runners-2:
<<: *runners
environment:
N8N_RUNNERS_TASK_BROKER_URI: http://n8n-worker-2:5679
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
depends_on: [n8n-worker-2]
caddy:
image: caddy:2.11.7-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
N8N_DOMAIN: ${N8N_DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on: [n8n-main, n8n-webhook]
volumes:
pg_data:
redis_data:
n8n_data:
caddy_data:
caddy_config:
A few choices worth explaining. OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true sends the "Execute workflow" button's runs to workers too, so the main process never needs a runner and never runs user code; the task runner docs say the alternative is not recommended for production. N8N_WEBHOOK_URL is the current name: the endpoints reference marks the old WEBHOOK_URL as deprecated from 2.35.0, still accepted as an alias with a startup warning. Binary data uses database because queue mode does not support the filesystem mode (each worker would have its own disk), and S3 storage is the other documented option. Redis runs with noeviction and an append-only file so that queued jobs are neither evicted under memory pressure nor lost on restart.
Validate the file before starting anything:
cd /opt/n8n && sudo docker compose config -q && echo OK
# OK
Step 4: Put Caddy in front for TLS and routing
Caddy fetches and renews a certificate for N8N_DOMAIN on its own, as long as the DNS record points at this host and ports 80 and 443 are reachable. The routing follows n8n's guidance: /webhook/* and /webhook-waiting/* go to the webhook pool, everything else (editor, REST API, /webhook-test/* for manual runs, and the editor's websocket) goes to main.
# /opt/n8n/Caddyfile (Caddy 2.11.7)
{$N8N_DOMAIN} {
encode zstd gzip
# Production webhooks and resumed waits go to the webhook processor
@webhooks path /webhook/* /webhook-waiting/*
handle @webhooks {
reverse_proxy n8n-webhook:5678
}
# Everything else (editor, REST API, /webhook-test/*, websocket push) goes to main
handle {
reverse_proxy n8n-main:5678
}
}
If only your office or VPN needs the editor, block everything except the public webhook paths at the proxy. This variant was checked with caddy adapt, which confirmed the 403 route is evaluated before the proxies:
{$N8N_DOMAIN} {
encode zstd gzip
# Only 198.51.100.0/24 (your VPN) may reach the editor and REST API
@not_allowed {
not path /webhook/* /webhook-waiting/*
not remote_ip 198.51.100.0/24
}
handle @not_allowed {
respond 403
}
@webhooks path /webhook/* /webhook-waiting/*
handle @webhooks {
reverse_proxy n8n-webhook:5678
}
handle {
reverse_proxy n8n-main:5678
}
}
If you use Form Trigger or a hosted Chat Trigger, their public pages live on other paths; copy the production URL from the trigger node and add its prefix to the not path line.
cd /opt/n8n && sudo docker run --rm -e N8N_DOMAIN=n8n.example.com \
-v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2.11.7-alpine \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# ... Valid configuration
Step 5: Start the stack and check health
cd /opt/n8n
sudo docker compose up -d
sudo docker compose ps --format '{{.Service}}\t{{.Status}}'
On my test run Postgres and Redis reported healthy after about six seconds and the n8n processes started immediately after. Give main 20 to 30 seconds on first boot, because it runs the database migrations. Then check each tier from inside the network (n8n's image includes wget):
sudo docker compose exec n8n-main wget -qO- http://127.0.0.1:5678/healthz/readiness; echo
sudo docker compose exec n8n-webhook wget -qO- http://127.0.0.1:5678/healthz; echo
sudo docker compose exec n8n-worker-1 wget -qO- http://127.0.0.1:5678/healthz/readiness; echo
# {"status":"ok"} (each)
/healthz says the process is up; /healthz/readiness also checks the database and Redis. On workers these endpoints only exist because QUEUE_HEALTH_CHECK_ACTIVE=true. If readiness returns {"status":"error"} in the first seconds, wait; mine did for about ten seconds while migrations finished. The logs should look like this (trimmed):
n8n-worker-1 | n8n Task Broker ready on 0.0.0.0, port 5679
n8n-worker-1 | Instance registered
runners-1 | INFO [launcher:js] Waiting for launcher's task offer to be accepted...
runners-1 | INFO [launcher:py] Waiting for launcher's task offer to be accepted...
n8n-main | Editor is now accessible via:
n8n-main | https://n8n.example.com
"Waiting for launcher's task offer to be accepted" is the idle state, not an error: the runner has connected and is waiting for work. On 2.43.8 every process also prints a deprecation notice about future defaults for N8N_RUNNERS_TASK_TIMEOUT (300 seconds today, 60 later) and the compression node limits. Set those explicitly if your workflows depend on the current values.
Step 6: Create the owner and prove the queue works
Open https://n8n.example.com, create the owner account, and build a two-node workflow: a Webhook trigger (POST, path queue-test, respond with the last node) and a Code node with return [{ json: { ok: true, n: $input.first().json.body.n * 2 } }];. Publish it, then call the production URL:
curl -s -X POST https://n8n.example.com/webhook/queue-test \
-H 'content-type: application/json' -d '{"n":21}'
# {"ok":true,"n":42}
sudo docker compose logs n8n-worker-1 n8n-worker-2 | grep -E 'Worker (started|finished)'
# n8n-worker-1 | Worker started execution 2 (job 2)
# n8n-worker-1 | Worker finished execution 2 (job 2)
That one request crossed every component: Caddy, the webhook processor, Redis, a worker and its runner. Two more checks are worth doing. Calling /webhook/queue-test on main directly should fail with Cannot POST, which proves production webhooks are off the main process. And a Code node that tries require('os') should fail with Module 'os' is disallowed, which is the runner's sandbox doing its job; I triggered both on the test stack.
Step 7: Back up Postgres, the key and the workflows
Three things make an n8n install recoverable: the Postgres database, the encryption key, and ideally a plain export of workflows that you can read and diff. Redis is a queue, not a source of truth.
#!/usr/bin/env bash
# /opt/n8n/backup.sh: nightly from cron, e.g. 15 2 * * * /opt/n8n/backup.sh
set -euo pipefail
cd /opt/n8n
ts=$(date -u +%Y%m%dT%H%MZ)
mkdir -p backups
# 1. Database, custom format (restore with pg_restore)
docker compose exec -T postgres pg_dump -U n8n -d n8n -Fc > "backups/n8n-$ts.dump"
# 2. Human-readable workflow and credential exports (credentials stay encrypted)
docker compose exec -T -u node n8n-main n8n export:workflow --backup --output=/home/node/.n8n/backups/latest/
docker compose exec -T -u node n8n-main n8n export:credentials --backup --output=/home/node/.n8n/backups/latest/
docker compose cp n8n-main:/home/node/.n8n/backups/latest "backups/export-$ts"
# 3. Ship off the host (restic, rclone, aws s3 cp ...) and prune old copies
find backups -mtime +14 -delete
The CLI docs describe --backup as shorthand for --all --pretty --separate. Do not add --decrypted to the credential export in a routine job: it writes every secret in plain text, and it exists for migrating to an instance with a different key. Test the restore at least once: bring up a throwaway copy with the same .env, pg_restore the dump into its empty database, and log in. A backup you have never restored is a hope.
Step 8: Upgrade safely
Upgrades are a version bump in two places (the n8n and runners images must match), a pull and a restart. Every n8n process acquires a database migration lock on start, so the first one up runs the migrations and the others wait.
cd /opt/n8n
./backup.sh # always, before migrations run
NEW=2.43.8 # set to the release you checked in Step 1
sudo sed -i -E "s#(n8nio/(n8n|runners)):[0-9.]+#\1:${NEW}#g" compose.yaml
grep -E 'n8nio/(n8n|runners):' compose.yaml # both lines must show the same version
sudo docker compose pull
sudo docker compose up -d
sudo docker compose exec n8n-main n8n --version
# 2.43.8
Read the release notes for anything crossing a minor version, and stay on a version for a day or two unless it fixes a published advisory, in which case patch now. For a major version (1.x to 2.x), work through the breaking-changes page on a copy first; the OAuth callback authentication change and the Code node defaults are the ones that break existing workflows.
Step 9: Keep it patched and boring
Subscribe to the repository's security advisories (Watch, Custom, Security alerts on github.com/n8n-io/n8n) and treat critical ones as same-day work. Keep the 2.0 defaults: leave NODES_EXCLUDE unset so Execute Command and Local File Trigger stay disabled, and keep N8N_BLOCK_ENV_ACCESS_IN_NODE at its default. Do not publish ports for anything except Caddy. If workflows run code that an AI agent wrote or that comes from untrusted input, remember the runner sidecar shares a network with the worker that holds your encryption key; I wrote more about that trade-off in the workflow automation post. Finally, execution data contains the full input and output of every node, which means API responses and prompts, so the 168-hour pruning above is a privacy control as much as a disk one.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Workers log credential decryption errors | N8N_ENCRYPTION_KEY differs between processes, or main generated its own key before you set one |
Use one key from .env for all n8n services; if main already encrypted credentials with a generated key, copy that key from /home/node/.n8n/config into .env |
| Code node fails or hangs with a task runner error | Runner image version differs from n8n, or the auth token does not match | Pin n8nio/runners to the exact n8n version and use the same N8N_RUNNERS_AUTH_TOKEN on both |
| Runner logs connection refused to port 5679 | Broker listening on localhost only | Set N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 on each worker and point each runner at its own worker |
Webhook URLs in the editor show localhost:5678 |
N8N_WEBHOOK_URL (or the old WEBHOOK_URL) not set |
Set it to https://your-domain/ on every n8n service and recreate the containers |
Production webhook returns 404 or Cannot POST |
Request reached main, which has production webhooks disabled, or the workflow is not published | Route /webhook/* to n8n-webhook in Caddy and publish the workflow |
| Executions stay queued and never start | Workers cannot reach Redis, wrong password, or no workers running | Check QUEUE_BULL_REDIS_* on workers, docker compose logs n8n-worker-1, and /healthz/readiness |
| Binary files missing between nodes | filesystem binary mode in queue mode |
Use N8N_DEFAULT_BINARY_DATA_MODE=database or S3 |
| Editor disconnects or never shows live execution progress | Proxy not passing websockets or wrong protocol settings | Use Caddy's reverse_proxy (websockets work by default), set N8N_PROTOCOL=https and N8N_PROXY_HOPS=1 |
| Caddy cannot get a certificate | DNS not pointing at the host, or ports 80/443 blocked | Fix the A/AAAA record and firewall, then docker compose logs caddy |
| IP allowlist blocks everyone | Caddy sees a different client address than you expect (another proxy or load balancer in front) | Configure Caddy's trusted_proxies and use the client_ip matcher, or drop the allowlist and use a VPN |
FAQ
Do I need queue mode for n8n?
Not for a handful of workflows. A single container with Postgres is fine for low volume. Queue mode is worth it when executions block the editor, when you need more than one machine's worth of throughput, or when you want webhook spikes isolated from the UI.
How many workers and what concurrency should I use?
Start with two workers at the default concurrency of 10 and watch queue depth and CPU. n8n recommends concurrency of at least 5, since many low-concurrency workers can exhaust the database connection pool. Add a worker by copying a worker and runner pair with the next number.
What happens if I lose the n8n encryption key?
Every stored credential becomes unreadable, even with a perfect database backup. Workflows survive, but you will have to re-enter every API key and OAuth connection. Keep the key in a secrets store separate from the server.
Is self-hosted n8n free?
The self-hosted Community edition is available on GitHub under n8n's Sustainable Use License, and queue mode works without a licence. Multi-main high availability needs a self-hosted Enterprise licence, and some admin features need a paid plan; the self-hosted Business plan is listed at EUR 667 a month billed annually on the pricing page.
When I inherit an n8n install, I check three things before touching anything else: whether the version is older than the latest critical advisory fix, whether the encryption key exists anywhere other than the server, and whether production webhooks still hit the editor process. Those three cover most of the outages and most of the breaches I have read about. The official references are the queue mode guide, the task runner guide, the 2.0 breaking changes, the CLI commands and the repository's security advisories.
Related: Workflow Automation in 2026: n8n Published 196 Advisories in Nine Months, 59,558 Hosts Sat Exposed, and the Code Alternative Raised at $12.55B, The Worms Learned to Use Your AI Agent: A Year of npm Supply-Chain Attacks and Self-Hosting vs SaaS in 2026: 37signals Saved $2M a Year. Should You?.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026)
Step-by-step GitHub Actions OIDC setup for AWS, Google Cloud and Azure with no stored secrets: trust policies pinned to sub claims, immutable subjects, environments, pinned actions, and fixes for sts:AssumeRoleWithWebIdentity, audience and AADSTS700213 errors.
14 minOct 20, 2026How to Fix ImagePullBackOff and ErrImagePull in Kubernetes (2026)
Fix Kubernetes ImagePullBackOff and ErrImagePull step by step: read the real event message, then fix wrong tags, moved Bitnami images, imagePullSecrets, ECR and Artifact Registry node permissions, Docker Hub 429 rate limits and arm64/amd64 mismatches.
13 minOct 20, 2026How to Migrate From ingress-nginx to Gateway API: Step-by-Step (2026)
A tested, step-by-step ingress-nginx to Gateway API migration: inventory annotations, install Gateway API v1.6.3 and Envoy Gateway v1.9.2, convert with ingress2gateway v1.2.0, handle TLS, rewrites and canaries, read route status conditions, cut over with DNS and roll back.
13 min