How to Self-Host n8n with Docker Compose in Queue Mode (2026)

Oct 20, 2026 · 16 min · Ajay Kumar

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.

n8n 2.43.8 queue mode on one Docker host Internet callers, browser Caddy 2.11.7 TLS, routing n8n-main editor, REST, /webhook-test n8n-webhook /webhook/*, /webhook-waiting/* Redis 8.8.3 Bull job queue workers x2 concurrency 10 each 1 per worker runners x2 Code node, port 5679 Public paths at Caddy /webhook/* to n8n-webhook /webhook-waiting/* to n8n-webhook everything else to n8n-main Only Caddy publishes host ports Postgres 17.11: workflows, encrypted credentials, executions every n8n process needs the same N8N_ENCRYPTION_KEY
The Compose file in this guide. Routing and the one-sidecar-per-worker rule follow n8n's queue mode and task runner documentation.

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