📥 Full docker-compose.yml + Queue Watchdog template included
Your n8n instance handles 20 workflows fine. Then you add a webhook-heavy lead pipeline, an AI agent that runs 3 minutes per execution, and a nightly batch sync — and suddenly everything queues up behind a single process. The UI freezes, webhooks time out, and you're restarting the container at 2 AM.
n8n queue mode puts a Redis queue between the main n8n process and multiple worker processes. The main process runs the UI, API, and all triggers; workers pull jobs from Redis and execute them in parallel. You enable it with one environment variable — EXECUTIONS_MODE=queue — on both main and workers, plus a PostgreSQL database (queue mode does not work with SQLite).
This guide is the complete production reference: migration from normal mode, the full docker-compose file (section 6), how to verify workers actually work (section 7), multi-VPS workers (section 8), and a free queue watchdog template (section 11).
On this page
- What Runs Where (The Truth)
- When to Use It (And When NOT)
- Migrating from Normal Mode
- Prerequisites
- The .env File (Encryption Key!)
- Full docker-compose.yml
- Verify Workers Actually Work
- Scaling + Multi-VPS Workers
- Webhooks in Queue Mode
- 9 Common Errors + Fixes
- Free: Queue Watchdog Template
- Zero-Downtime Updates
- FAQ
- Sources & References
1. What Runs Where (The Truth)
Most guides draw the architecture but never explain the division of labor. This table is the part that saves you hours of confusion:
| Component | Runs on | What it does |
|---|---|---|
| UI / Editor | Main process only | The n8n web interface, API, CLI |
| All triggers | Main process only | Schedule, webhook, and polling triggers. Workers never run triggers — you never activate workflows on workers. |
| Workflow executions | Workers | Every execution is enqueued by main and claimed by a free worker |
| Redis | Its own container | The Bull job queue: wait, active, completed, failed job lists |
| PostgreSQL | Its own container | Workflows, executions, credentials — the shared state every worker reads |
Why this matters: a trigger fires on main, main writes a job to Redis, a worker claims it and executes. The workflow itself — nodes, credentials, activation state — lives in Postgres, so any worker can run any workflow without local configuration. This is also why SQLite is impossible: it can't serve concurrent writers.
2. When to Use It (And When NOT)
Queue mode is not "the pro setup" — it's a scaling decision with real costs: two extra services to maintain, back up, and monitor. Choose based on symptoms, not ambition:
| Symptom you're seeing | Verdict |
|---|---|
| UI is slow or unresponsive while workflows run | ✅ Strong signal for queue mode |
| Webhook providers report timeouts during heavy executions | ✅ Strong signal — plus the webhook fix in section 9 |
| One heavy workflow delays all the others | ✅ Queue mode isolates it on a worker |
| You need zero-downtime updates | ✅ Rolling worker updates (section 12) |
| Under a few hundred executions/day, single workflow at a time | ❌ Stay in normal mode — SQLite simplicity wins |
| You're on n8n Cloud | ❌ Queue mode is a self-hosted concern — Cloud is managed |
Cost of admission on a self-hosted VPS: Redis needs ~100–200 MB, Postgres ~200–500 MB, each worker ~300–500 MB plus heap. Plan for ~1 GB of extra RAM on top of your current footprint — the sizing table in our Out of Memory guide covers the full picture.
3. Migrating from Normal Mode (SQLite → Postgres)
This is the step every other guide skips — and it's where people lose their workflows. The safe path: export everything from the old instance, build the new stack, import, verify, switch.
Export workflows and credentials from the OLD instance
Use n8n's built-in CLI — it bulk-exports everything in one command:
# Export ALL workflows as JSON files
docker compose exec n8n n8n export:workflow --all --output=/home/node/.n8n/export
# Export ALL credentials
docker compose exec n8n n8n export:credentials --all --output=/home/node/.n8n/export
# Copy the export folder out of the container
docker compose cp n8n:/home/node/.n8n/export ./n8n-export
Build the new stack with the SAME encryption key
Use the docker-compose from section 6, but set N8N_ENCRYPTION_KEY to the same key as your old instance. Credentials are encrypted with this key — importing with a different key means re-entering every credential by hand.
Import into the new instance
Copy the files in and import:
# Copy the export into the NEW container
docker compose cp ./n8n-export n8n:/home/node/.n8n/export
# Import workflows and credentials
docker compose exec n8n n8n import:workflow --input=/home/node/.n8n/export
docker compose exec n8n n8n import:credentials --input=/home/node/.n8n/export
Verify, activate, switch
Open the new UI and confirm every workflow appears with its credentials. Re-activate your workflows (activation state doesn't carry over). Test one webhook end-to-end. Only then point your domain at the new instance.
4. Prerequisites
- A VPS or server with Docker + Docker Compose (2 GB RAM minimum; 4 GB comfortable).
- Your n8n already running per our Self-Hosted Docker installation guide — or a fresh server.
- A domain/subdomain (e.g.,
n8n.yourdomain.com) with a reverse proxy. Webhook URLs work the same as normal mode — see section 9 for timing. - About 45 minutes for a careful migration; 20 for a fresh install.
5. The .env File (Encryption Key!)
Two values power the whole stack. The second one causes 90% of queue mode headaches:
# .env — place next to docker-compose.yml
# Encryption key — MUST be identical on main AND every worker.
# Generate with: openssl rand -hex 24
N8N_ENCRYPTION_KEY=your-random-48-character-hex-key
# PostgreSQL password
POSTGRES_PASSWORD=change-me-to-something-long
# Optional: Redis password (leave commented for a private Docker network)
# REDIS_PASSWORD=change-me-too
N8N_ENCRYPTION_KEY. If main and workers have different keys, workers can't decrypt credentials and every workflow fails — silently. One key, in .env, referenced by every container. Never change it later without exporting and re-importing credentials.6. Full docker-compose.yml
This is the complete production stack: Postgres (state) + Redis (queue) + main (UI/API/triggers) + 2 workers (execution). It includes the production hardening most guides omit: stalled-job detection, completed-job cleanup, graceful shutdown, and health checks.
version: '3.8'
volumes:
n8n_data:
postgres_data:
redis_data:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=n8n
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U n8n']
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
# Remove the password args if Redis only listens on the private Docker network
command: ['redis-server', '--appendonly', 'yes', '--requirepass', '${REDIS_PASSWORD:-defaultpass}']
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
ports:
- '5678:5678'
environment:
# Core
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=Africa/Tunis
- WEBHOOK_URL=https://n8n.yourdomain.com
# Database (required for queue mode)
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
# Queue mode
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
- QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD:-defaultpass}
- QUEUE_BULL_PREFIX=n8n-queue
- QUEUE_HEALTH_CHECK_ACTIVE=true
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
n8n-worker:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
command: worker
environment:
# Core — encryption key MUST match the main process
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- GENERIC_TIMEZONE=Africa/Tunis
# Database — same shared Postgres
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
# Queue mode — same Redis, password, and prefix as main
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
- QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD:-defaultpass}
- QUEUE_BULL_PREFIX=n8n-queue
# Worker tuning
- QUEUE_WORKER_CONCURRENCY=10
# Stalled-job recovery: re-queue jobs whose worker died mid-execution
- QUEUE_WORKER_STALLED_INTERVAL=60000
- QUEUE_WORKER_MAX_STALLED_COUNT=3
# Finish running jobs before shutting down (updates, scale-down)
- N8N_GRACEFUL_SHUTDOWN_TIMEOUT=30
# Keep Redis memory in check
- QUEUE_BULL_JOB_OPTIONS_REMOVE_ON_COMPLETE=1000
- QUEUE_BULL_JOB_OPTIONS_REMOVE_ON_FAIL=1000
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
n8n:
condition: service_started
Launch it
# From the folder containing docker-compose.yml and .env
docker compose up -d
# Then create 2 worker replicas
docker compose up -d --scale n8n-worker=2
Notes on this file
- Pin your n8n image version (e.g.,
docker.n8n.io/n8nio/n8n:1.120.0) instead of floating onlatest— a bad release should never take your production stack down overnight. Postgres and Redis are pinned to major versions above. QUEUE_BULL_PREFIXisolates your queue inside Redis. Running two n8n instances (e.g.,n8n-prodandn8n-test) against one Redis server is fine — as long as each uses a different prefix, jobs never mix.- Workers don't need port 5678 — only main exposes it. Workers reach Redis and Postgres over the Docker network.
QUEUE_WORKER_STALLED_INTERVAL(ms): how often n8n checks for jobs whose worker died mid-execution. Paired withQUEUE_WORKER_MAX_STALLED_COUNT(how many times a stalled job is re-queued before failing), stuck jobs recover automatically. Important: a workflow that legitimately runs longer than the stalled interval can be falsely flagged as stalled — if you run long AI workflows, raise the interval (e.g.,120000).- Same
n8n_datavolume on workers — intentional: workers need the same.n8nfolder for files and community nodes (single-host setups only; see section 8 for multi-VPS). - Redis password: the snippet uses
${REDIS_PASSWORD:-defaultpass}so it runs out of the box. On a private Docker network you can drop the--requirepassargs and theQUEUE_BULL_REDIS_PASSWORDlines entirely. - Newer n8n versions also accept
QUEUE_BULL_CONCURRENCYas the modern name forQUEUE_WORKER_CONCURRENCY— either works; don't set both.
7. Verify Workers Actually Work (No Guide Covers This)
Everything can look healthy while workers silently do nothing. Run these five checks after every setup or change:
All containers healthy
docker compose ps — Postgres, Redis, main, and workers all healthy/Up. A worker stuck in Restarting means a config error (usually Redis or DB connection).
Workers registered in the UI
In queue mode, n8n's Settings shows the registered workers and their status. If a worker doesn't appear, it's not connected to the same Redis — check its QUEUE_BULL_* variables (error #3 in section 10).
Run a test workflow and watch it complete
Create a tiny workflow (Schedule or Manual trigger → NoOp), execute it, and confirm it finishes with success. Then check the worker logs to see it was claimed:
docker logs n8n-worker-1 --tail 50
Watch a job move through Redis in real time
Trigger the test workflow while MONITOR runs — you'll see the job claimed by a worker:
docker compose exec redis redis-cli MONITOR
# Ctrl+C to stop
Check the queue depth is zero after the run
The wait list holds jobs no worker has claimed yet. After a healthy run it should be back to zero:
docker compose exec redis redis-cli LLEN n8n-queue:wait
EXECUTIONS_MODE missing on the workers. Main enqueues jobs, but no one is listening. Step 2 catches it in ten seconds.8. Scaling: Replicas, Concurrency, and Multi-VPS Workers
Two levers on one machine:
| Lever | What it does | How |
|---|---|---|
| Worker replicas | More parallel processes pulling from Redis | docker compose up -d --scale n8n-worker=4 |
QUEUE_WORKER_CONCURRENCY |
More simultaneous jobs per worker (default 10) | Set to 20 and restart workers — lower it to 3–5 for memory-heavy workflows |
8.1 Multi-VPS workers (distributed)
When one VPS can't hold your load, run workers on a second server. They're just Docker containers pointed at the same Redis and Postgres over the network:
# On VPS #2 — a worker-only compose file
services:
n8n-worker:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
command: worker
environment:
# Same key as VPS #1 — non-negotiable
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Point at VPS #1's services (private IP preferred)
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=10.0.0.1
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=10.0.0.1
- QUEUE_BULL_REDIS_PORT=6379
- QUEUE_BULL_REDIS_PASSWORD=${REDIS_PASSWORD}
- QUEUE_BULL_PREFIX=n8n-queue
- QUEUE_WORKER_CONCURRENCY=10
volumes:
- worker_data:/home/node/.n8n
- Networking: don't expose Redis or Postgres to the public internet. Use a private network — Tailscale or WireGuard between the VPSs is the clean way, or your provider's private VLAN (the
10.0.0.1above). - Community nodes: each worker needs the community nodes your workflows use installed on its own volume — a shared
n8n_datavolume doesn't cross VPS boundaries. - Latency: Redis and DB round-trips across the internet add execution overhead. Private networks keep this minimal; two VPSs in the same region/datacenter is the sane default.
- Sizing rule of thumb: start with 2 workers × 10 concurrency = 20 parallel executions. Workers pinned at 100% CPU with a growing queue → scale up. Workers idling at 20% → scale down (they finish running jobs first, thanks to
N8N_GRACEFUL_SHUTDOWN_TIMEOUT).
9. Webhooks in Queue Mode (The Timing Trap)
In normal mode, a webhook execution runs locally and responds fast. In queue mode, the main process receives the request but the execution waits for a worker — so the HTTP response is delayed by your queue latency. Stripe, GitHub, and Shopify time out after ~10–30 seconds, then retry — causing duplicate processing.
The fix is two settings on your Webhook node:
- Response Mode: Immediately — main replies
200the moment it enqueues the job. The provider stops retrying. - Respond to Webhook node — when the workflow needs to send a custom body or status, place this node where the response should be built and configure the Webhook node to respond from it.
10. The 9 Most Common Errors & Fixes
| # | Error / Symptom | Cause | Fix |
|---|---|---|---|
| 1 | Workers never pick up jobs; queue stays full | EXECUTIONS_MODE=queue set on main but missing on workers |
Add it to every worker container and restart them |
| 2 | Every workflow fails with a credential decryption error | N8N_ENCRYPTION_KEY differs between main and workers |
One identical key everywhere; re-enter affected credentials once |
| 3 | connect ECONNREFUSED 127.0.0.1:6379 |
Pointing Redis at localhost instead of the Docker service name | Use QUEUE_BULL_REDIS_HOST=redis (the service name), not 127.0.0.1 |
| 4 | Worker restarts in a loop after enabling a Redis password | Redis requires auth but workers don't send it | Set QUEUE_BULL_REDIS_PASSWORD on main AND every worker |
| 5 | Executions stuck in "running" forever, or error job stalled more than maxStalledCount |
A worker crashed mid-execution — OR a workflow legitimately ran longer than the stalled interval | Redis persistence (--appendonly yes), N8N_GRACEFUL_SHUTDOWN_TIMEOUT=30, stalled-interval settings (section 6) |
| 6 | Redis memory keeps growing until OOM | Completed/failed job metadata never removed | QUEUE_BULL_JOB_OPTIONS_REMOVE_ON_COMPLETE=1000 + REMOVE_ON_FAIL=1000, and a Redis maxmemory with allkeys-lru |
| 7 | Webhook providers report timeouts after switching to queue mode | Webhook responses now wait for the worker to finish the execution | Webhook Response Mode: Immediately + Respond to Webhook node (section 9) |
| 8 | Jobs vanish or mix between two n8n instances | Both instances share Redis with the same QUEUE_BULL_PREFIX |
Unique prefix per instance (e.g., n8n-prod, n8n-test) |
| 9 | Multi-VPS workers can't connect to Redis/Postgres | Firewall or public exposure — the ports aren't reachable privately | Use Tailscale/WireGuard or the provider's private VLAN; never expose 6379/5432 publicly (section 8.1) |
11. Free Template: The Queue Watchdog
Monitoring doesn't stop at /healthz. This workflow probes the actual queue depth every 5 minutes (Redis LLEN on the wait list) and alerts you on Telegram when jobs are backing up — before your webhook providers start complaining.
{
"name": "n8n Queue Watchdog",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 5
}
]
}
},
"id": "schedule",
"name": "Every 5 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [250, 300]
},
{
"parameters": {
"operation": "listLength",
"key": "n8n-queue:wait"
},
"id": "redis-depth",
"name": "Queue Depth (LLEN)",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [450, 300],
"credentials": {
"redis": {
"id": "YOUR_REDIS_CREDENTIAL_ID"
}
}
},
{
"parameters": {
"jsCode": "const depth = $input.item.json.length;\nconst threshold = 20;\nconst now = Date.now();\nconst lastAlert = $workflow.staticData.lastAlert || 0;\nconst notify = depth > threshold && (now - lastAlert > 900000);\nif (notify) { $workflow.staticData.lastAlert = now; }\nreturn [{ json: { depth, threshold, notify, timestamp: new Date().toISOString() } }];"
},
"id": "evaluate",
"name": "Evaluate Depth",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [650, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.notify }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "if-alert",
"name": "Backlog?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [850, 300]
},
{
"parameters": {
"chatId": "YOUR_TELEGRAM_CHAT_ID",
"text": "=🚦 n8n QUEUE BACKLOG\n\nWaiting jobs: {{ $json.depth }}\nThreshold: {{ $json.threshold }}\nTime: {{ $json.timestamp }}\n\nWorkers are falling behind — check worker health or scale up."
},
"id": "telegram",
"name": "Telegram Alert",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [1050, 200],
"credentials": {
"telegramApi": {
"id": "YOUR_TELEGRAM_CREDENTIAL_ID"
}
}
},
{
"parameters": {},
"id": "noop-ok",
"name": "Queue Healthy",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1050, 400]
}
],
"connections": {
"Every 5 Minutes": {
"main": [
[
{
"node": "Queue Depth (LLEN)",
"type": "main",
"index": 0
}
]
]
},
"Queue Depth (LLEN)": {
"main": [
[
{
"node": "Evaluate Depth",
"type": "main",
"index": 0
}
]
]
},
"Evaluate Depth": {
"main": [
[
{
"node": "Backlog?",
"type": "main",
"index": 0
}
]
]
},
"Backlog?": {
"main": [
[
{
"node": "Telegram Alert",
"type": "main",
"index": 0
}
],
[
{
"node": "Queue Healthy",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true
}
}
- Redis credential: create one Redis credential in n8n pointing at
redis(or the private IP for multi-VPS) on port 6379 with your password. - The key
n8n-queue:waitis the waiting-jobs list — change the prefix if you customizedQUEUE_BULL_PREFIX. - Threshold 20: tune it. 20 waiting jobs is nothing at 100 executions/minute; it's an emergency at 2/minute. Alert once per 15 minutes max (cooldown built in).
- Also monitor /healthz for the main process with UptimeRobot or our Website Downtime Checker template — watchdog + health checks cover both sides.
12. Zero-Downtime Updates
Queue mode's best operational gift: you can update n8n without downtime. Order matters — workers first, main last:
# 1. Pull the new (pinned) image
docker compose pull
# 2. Restart workers one by one — running jobs finish first
# (graceful shutdown via N8N_GRACEFUL_SHUTDOWN_TIMEOUT)
docker compose up -d --no-deps --scale n8n-worker=0 n8n-worker
docker compose up -d --no-deps --scale n8n-worker=2 n8n-worker
# 3. Update the main process last
docker compose up -d --no-deps n8n
latest, every restart is a surprise.13. Frequently Asked Questions
No. Queue mode requires PostgreSQL. SQLite can't handle concurrent writes from multiple workers, so n8n won't run queue mode without a Postgres database. Section 3 covers the migration path.
No. A single instance in normal mode works perfectly without Redis. Queue mode only pays off when you need multiple workers, high concurrency, or isolation between the main process and heavy workflows.
No. All triggers — schedule, webhook, and polling — run on the main process only. Workers only execute the jobs main enqueues. You never need to activate workflows on workers.
The #1 cause is EXECUTIONS_MODE=queue set on the main process but missing from the worker containers. Both must have it. Then verify both use the same Redis host, port, password, and QUEUE_BULL_PREFIX — section 7 gives you the 5-step verification.
Your N8N_ENCRYPTION_KEY differs between main and workers. Credentials are encrypted with this key, so it must be identical everywhere. Fix the key, then re-enter the affected credentials once.
Yes. Main receives webhook requests and enqueues them for a worker. But the response waits for the execution to finish — under load, set the Webhook node's Response Mode: Immediately and reply with a Respond to Webhook node (section 9).
Start with 2 workers. Each handles up to QUEUE_WORKER_CONCURRENCY jobs in parallel (default 10). Scale up when CPU saturates or the Redis queue grows at peak hours; scale down when workers idle.
Yes. Workers on a second VPS just need to reach the same Redis and PostgreSQL over the network — ideally through Tailscale or WireGuard — and use the same N8N_ENCRYPTION_KEY. Install community nodes on each worker. Full compose file in section 8.1.
Update workers first: pull the new image, restart workers one by one, and keep the main process running. Then update main last. Running executions finish on old workers while new ones go to updated workers (section 12).
Queue mode distributes entire workflow executions across worker processes. Task runners (the newer N8N_RUNNERS_* feature) offload only JavaScript/Python Code node execution to separate runner processes. They solve different problems and can be combined — a worker can hand its Code nodes to runners.
Sources & References
- n8n Documentation — Queue mode
- n8n Documentation — Configure scaling (workers, concurrency, stalled jobs)
- n8n Documentation — Queue mode environment variables
- n8n Documentation — Redis node
- n8n Documentation — CLI commands (export/import)
