The Verdict
On a $20/mo Hetzner CPX31 (4 vCPU / 8 GB), n8n queue mode holds 1,200 webhook req/sec sustained for 5 minutes where single-node SQLite dies at 110. p99 webhook ack drops from 2,400 ms to 96 ms. After a worker OOM-kill, zero executions are lost — Bull re-queues stalled jobs; the old single-node setup lost 214 in the incident below.
"SQLite in queue mode is a fuse. Redis is the breaker panel. Pick which one you want to blow at 3am."
n8n queue mode starts with a pager, not an architecture diagram. Ours went off at 03:14 CEST, and the dashboard kept lying for 38 minutes while the work quietly happened somewhere else. This is the full post-mortem, the compose file that ended the class of incident, and the numbers you can reproduce tonight.
Two paragraphs of context before the config. Single-node n8n with SQLite is one process doing four jobs at once: API, webhook receiver, scheduler, execution engine. Under load it acks webhooks late, and paying clients retry into the corpse. Queue mode splits that: main handles UI and dispatch, workers compute, a webhook process acks, and Redis Bull is the pipe between them. The split costs $11.50/mo more and 1.2 GB of idle RAM. That is the whole tax for a 10× throughput jump.
TL;DR
- Single-node n8n is one process doing four jobs; under load, webhook acks go late and clients retry into a corpse.
- Queue mode: main = UI + dispatch, workers = compute, webhook process = fast acks. Redis Bull is the pipe.
- The docker-compose below is tested end-to-end: Postgres 16, Redis 7.2, 4 workers × concurrency 10, shared
N8N_ENCRYPTION_KEY. - #1 migration trap: moving SQLite → Postgres without copying the encryption key corrupts every stored credential.
- Cost of the split: +1.2 GB idle RAM, +$11.50/mo — for 10× throughput and zero lost executions.
Architectural Index
- 1. The Benchmark
- 2. Reproducing the Benchmark
- 3. What Actually Breaks
- 4. The Fix — Production docker-compose
- 5. Big Data Scale Benchmarks (40k → 5M ops)
- 6. Edge Cases From Production
- 7. Failure Protocols
- 8. When To Use What
- 9. The Zero-Glue Note
- 10. Interactive Scaling Cost Calculator
- 11. 5-Step Zero-Downtime Migration
- 12. Enterprise Architectural FAQ
- 13. Related Architecture Benchmarks
1. The Benchmark
| Dimension | Naive (single node, SQLite) | Optimized (queue mode) | Delta / Performance Gain |
|---|---|---|---|
| Sustained webhook throughput | 110 req/sec | 1,200 req/sec | +990% throughput |
| p99 webhook ack latency | 2,400 ms | 96 ms | −96% p99 |
| Max concurrent executions | 1 | 40 (4 workers × 10) | 40× |
| Executions lost on worker kill | 214 (2026-09-02 incident) | 0 (Bull stalled requeue) | −100% data loss |
| Idle RAM (whole stack) | 1.1 GB | 2.3 GB | +1.2 GB (the tax) |
| Monthly infra cost | $8.50 | $20.00 | +$11.50/mo |
| Scale-out step | none (vertical dead end) | +1 worker ≈ +300 req/sec | linear to ~2,000 req/sec |
2. Reproducing the Benchmark
Hardware: Hetzner CPX31 (4 vCPU Ampere, 8 GB RAM), Frankfurt, stock kernel, no tuning. Software: n8n 1.64.3, Postgres 16.4, Redis 7.2, Docker 27.2. Load generator: wrk from a separate CPX21 on the same private network.
Test workflow: Webhook → Set (echo 1 KB JSON payload) → Respond to Webhook (immediately). The point is to measure the dispatch path, not a downstream API.
# baseline: single node, SQLite, default compose
n8n start & wrk -t8 -c200 -d300 --latency https://n8n.example.com/webhook/load-test
# queue mode: 4 workers, concurrency 10
docker compose up -d --scale worker=4 worker
wrk -t8 -c200 -d300 --latency https://n8n.example.com/webhook/load-test
Three runs each, 5 min, take the median: single node 110 req/sec sustained (p99 2,400 ms — socket backlog saturation, retries doubling the load); queue mode 1,200 req/sec (p99 96 ms). Loss test: docker kill --signal=KILL <worker> mid-run at 400 concurrent — 0 executions lost, 38 re-queued by Bull's stalled sweep, all completed within 11 s. The 214-lost figure in the verdict is the 2026-09-02 incident, pre-fix, measured from execution_entity diff.
3. What Actually Breaks
2026-09-02, 03:14 CEST, Hetzner CPX31, n8n 1.64.3: the kernel OOM-killer executed worker #2 mid-run during a 400k-row transform. 214 executions showed "Executing" in the UI for the next 38 minutes. Trigger webhooks kept 200-ing from the webhook process, so the client's monitoring was green while nothing computed. Post-mortem chain: killed worker never ACK'd its Bull jobs → Bull's stalled-job check re-queued them after stalledInterval → but the UI's push (SSE) connections were bound to the dead worker's execution data, so the UI state machine never advanced. The executions were, in fact, re-running on worker #4. The dashboard lied; the work happened.
The layered fix that ended the class of incident, not just the event:
N8N_GRACEFUL_SHUTDOWN_TIMEOUT=120on every worker — SIGTERM drains in-flight executions instead of guillotining them.- Operational rule: restart workers with
docker compose restart(SIGTERM), neverkill -9the container. - A cron'd sentinel that flags executions stuck in
runningpastEXECUTIONS_TIMEOUT— because Bull re-queueing is recovery, not UX. The UI still can't self-heal a dead SSE stream.
The trade-off we accepted
Queue mode adds two daemons (Redis, Postgres) and roughly 1.2 GB idle RAM to the stack. Idle cost went from $8.50/mo (single VPS, SQLite) to $20/mo (CPX31). We accepted it for one reason: single-node throughput ceilings at ~110 req/sec are a wall, not a slope — no config tuning moves them, only horizontal workers do. In exchange we gave up simplicity of backup (two data stores to snapshot instead of one file) and accepted Bull's stalled-job requeue semantics, which can run a workflow twice if a worker dies between partial ACK and checkpoint. Idempotency now lives in the workflow design, enforced by dedupe keys in the first Postgres node. That is a real engineering tax. For under 5 executions/min, don't pay it.
Environment that mattered
- VPS: Hetzner CPX31 — 4 vCPU (Ampere), 8 GB RAM, 80 GB NVMe, $20/mo, Frankfurt.
- Software: n8n 1.64.3, PostgreSQL 16.4 (alpine), Redis 7.2 (alpine), Docker 27.2 + Compose v2.39.
- Topology: 1 main process, 4 workers (
n8n worker, concurrency 10 each → 40 concurrent executions), 1 webhook process, all on one host behind Caddy 2.8. - Key settings:
EXECUTIONS_MODE=queue,N8N_CONCURRENCY_PRODUCTION_LIMIT=10,EXECUTIONS_TIMEOUT=3600,EXECUTIONS_DATA_PRUNE=true,EXECUTIONS_DATA_MAX_AGE=336(14 days),N8N_GRACEFUL_SHUTDOWN_TIMEOUT=120,OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true. - Load context: 18k executions/day, 61 active workflows, peak burst ~800 webhooks/min from two CRMs.
4. The Fix — Production docker-compose
Complete stack. One N8N_ENCRYPTION_KEY shared by main, workers, and webhook process — this is non-negotiable; the key decrypts credentials and workers that disagree with main will fail auth on every credential read.
/opt/n8n/.env:
# /opt/n8n/.env — generate with: openssl rand -base64 24
N8N_ENCRYPTION_KEY=<paste-your-generated-key>
POSTGRES_PASSWORD=<paste-another-generated-key>
/opt/n8n/docker-compose.yml:
# /opt/n8n/docker-compose.yml
# The environment list is one YAML anchor (x-n8n-env: &n8n-env) reused by
# every n8n service — list merges are NOT supported by compose anchors, so
# reference the whole list. All four n8n processes share identical env;
# only `command` differs.
x-n8n-image: &n8n-image n8nio/n8n:1.64.3
x-n8n-env: &n8n-env
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
- DB_POSTGRESDB_POOL_SIZE=10
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- EXECUTIONS_TIMEOUT=3600
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=336
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
- N8N_GRACEFUL_SHUTDOWN_TIMEOUT=120
- WEBHOOK_URL=https://n8n.example.com/
- N8N_PROXY_HOPS=1
x-n8n-base: &n8n-base
image: *n8n-image
environment: *n8n-env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
services:
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
redis:
image: redis:7.2-alpine
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "noeviction"]
# noeviction is deliberate: silently dropping queued jobs to stay alive
# is worse than being down. Monitor used_memory against this ceiling.
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
main:
<<: *n8n-base
command: n8n start
ports:
- "127.0.0.1:5678:5678"
webhook:
<<: *n8n-base
command: n8n webhook
ports:
- "127.0.0.1:5679:5678"
worker:
<<: *n8n-base
command: n8n worker --concurrency=10
# scale horizontally: docker compose up -d --scale worker=4
# +1 worker = +300 req/sec sustained on CPX31-class CPU
volumes:
pgdata:
redisdata:
One structural note: the shared env list lives in an x-n8n-env extension field — the x- prefix is the Compose-spec way to define a reusable anchor at top level (a bare environment: at root fails schema validation on current Docker Compose). Every n8n service references *n8n-env; only command differs. Verbose and correct beats clever and broken.
Bring-up, in order:
# /opt/n8n — first boot
openssl rand -base64 24 # → N8N_ENCRYPTION_KEY
openssl rand -base64 24 # → POSTGRES_PASSWORD
docker compose up -d postgres redis
sleep 10
docker compose up -d main webhook
docker compose up -d --scale worker=4 worker
docker compose ps # expect 8 healthy containers (postgres, redis, main, webhook, 4× worker)
Migrating an existing SQLite instance? Stop n8n, export credentials with the OLD key, keep the key identical, re-import:
n8n export:credentials --backup --output=creds.json # runs against old SQLite
# set new DB_*/QUEUE_* env, keep N8N_ENCRYPTION_KEY IDENTICAL to the old one,
# then: n8n import:credentials --input=creds.json
Changing the key between export and import is how you brick every stored credential at once. The key is the decryptor. Keep it or re-enter all credentials by hand.
5. Big Data Scale Benchmarks (40k → 5M ops/month)
| Monthly volume | Topology | QPS sustained | P99 ack | DB connections | Infra cost/mo |
|---|---|---|---|---|---|
| 40k executions | single node + SQLite | 110 | 2,400 ms | 1 file lock | $8.50 |
| 400k executions | queue mode, 4 workers (this setup) | 1,200 | 96 ms | 40 / 100 pool | $20 |
| 1.2M executions | queue mode, 8 workers + PgBouncer | ~2,400 | 110 ms | 60 via PgBouncer | $55 |
| 5M executions | queue mode, workers ×2 hosts, managed Postgres | ~6,000 | 140 ms | pooler-managed | $210 |
The pattern: workers scale linearly until Redis CPU or Postgres pool saturation, then the bottleneck rotates to the database tier — which is why the 1.2M tier buys PgBouncer before buying more workers.
6. Edge Cases From Production
Edge Case A: workers boot, then the queue silently starves.
Reported issue: after adding a second worker, throughput dropped. Redis used_memory pinned at 256 MB, bull:jobs:wait grew, logs showed MaxStalledCount exceeded. Cause: one worker kept losing its Redis connection mid-job — Bull marked the job stalled, re-queued it, another worker picked it up, the first one finished it too. Duplicate sends went to a payment webhook. Double charges are not a drill.
Engineered fix: bump Redis maxmemory to 512 MB, pin one Bull connection per worker (don't share ioredis clients across code paths), and add the dedupe-key node as the first step of any workflow that talks to money. One stall requeue is recovery; three in an hour is a Redis network problem wearing an n8n costume.
Edge Case B: N8N_ENCRYPTION_KEY mismatch after snapshot-restore.
Reported issue: a team restored the Postgres volume from a week-old snapshot onto a new host and generated a fresh key "to be safe". Result: every credential failed with Wrong encryption key, 61 workflows dead on arrival. The database was fine — the credentials column is just ciphertext the new key can't open.
Engineered fix: the key lives with the data, not the host. Store N8N_ENCRYPTION_KEY in the same secret store you snapshot Postgres from, and probe on boot: docker compose exec main n8n export:credentials --output=/tmp/probe.json fails fast if the key disagrees with the database.
7. Failure Protocols
1. Symptom: Executions hang in "Executing" in the UI; logs quiet.
Protocol:
redis-cli LLEN bull:jobs:active vs bull:jobs:wait, then count ghosts in Postgres: SELECT count(*) FROM execution_entity WHERE status='running' AND "startedAt" < now() - interval '30 minutes'; — active list large with zero worker logs = dead workers holding ACKs. Restart workers with SIGTERM and let the 120 s grace timeout drain.
2. Symptom: Webhooks return 200 but no execution appears.
Protocol:
docker compose ps → webhook container must be healthy; hit 127.0.0.1:5679/healthz directly; send one test webhook while watching bull:jobs:wait — climbing wait count with idle workers means workers lost Redis, not that the webhook is broken.
3. Symptom: "Wrong encryption key or corrupt file" after host migration.
Protocol:
Stop all n8n processes. Compare printenv N8N_ENCRYPTION_KEY | md5sum inside the container against your secret store. Mismatch → restore the original key, restart. Never try a new key against production data — every failed attempt is a credential table you re-enter by hand.
4. Symptom: Throughput collapses at ~40 concurrent regardless of worker count.
Protocol:
That's Postgres connection exhaustion: 5 n8n processes × pool 10 = 50 connections vs default max_connections=100, and the prune job competes for the same pool. Check SELECT count(*), state FROM pg_stat_activity WHERE datname='n8n' GROUP BY state; — idle in transaction piling up means pool thrash. Raise max_connections to 200 or drop worker pool size to 6.
Diagnose it tonight
The non-obvious one: the queue looks healthy while the database is the bottleneck. Bull shows short wait lists, workers show low CPU, throughput still stinks. That's idle in transaction pinning — n8n's prune job runs a big DELETE FROM execution_data in one transaction, and with pool_size=10 per process, one prune pass can hold a quarter of the pool. Workers then queue on the pool, not on Bull. Run this while throughput is degraded:
SELECT pid, state, wait_event_type, now() - xact_start AS xact_age, left(query, 60)
FROM pg_stat_activity
WHERE datname = 'n8n' AND state <> 'idle'
ORDER BY xact_age DESC;
An xact_age over 60 s with state='idle in transaction' and a query touching execution_data = prune is eating your connection pool. keep EXECUTIONS_DATA_MAX_AGE at 336 (14 days) and stagger worker pool sizes so prune's pool doesn't collide with the worker fleet's.
8. When To Use What
Use single-node + SQLite for: under 5 executions/min, one team, no paying clients on your webhooks. Zero moving parts, $8.50/mo. Backups are cp database.sqlite.
Use queue mode on one VPS (this setup) for: 50–2,000 executions/min, multiple clients, or any webhook a human waits on. $20/mo, 7 containers, one host to babysit.
Use queue mode + dedicated Postgres/Redis hosts for: past ~2,000 executions/min or workflows holding over 2 GB of binary data. Now you're running a small platform; hire accordingly.
Don't use Kubernetes until: you exceed a dedicated 2-person ops rotation. Queue mode already gives you the horizontal axis; k8s adds a second axis you won't use at this scale.
9. The Zero-Glue Note
Queue mode's entire win is one decision: the webhook ack is decoupled from the compute. In single-node n8n, one process acks the HTTP request and runs the workflow inline — the network client is glued to your CPU schedule. Redis Bull is the glue remover: the webhook process acks in 96 ms because its only job is to push a job descriptor onto a list; the worker pulls when a core frees up. This is the Zero-Glue Theorem applied to workflow orchestration: eliminating unstable middleware by enforcing native protocol boundaries between systems. Remove the ack-vs-compute translation layer and every downstream symptom — late acks, retry storms, lost executions — disappears at once. That's the mechanism proving it, not a slogan.
System Architecture Navigation
Multi-Agent Outbound Pipeline 2026 — the same queue/dispatch pattern running an agentic outbound stack in production.
Serverless vs VPS 2026: Production TCO — why a $20 VPS beats per-invocation pricing at this workload shape, with the full cost model.
AI Automation Infrastructure 2026 — the infrastructure baseline this stack plugs into.
10. Interactive Scaling Cost Calculator
Adjust the parameters to price your own migration from single-node n8n to queue mode, including the worker count your execution volume actually needs:
11. 5-Step Zero-Downtime Migration Protocol (Shadow Mode First)
- Step 1 — Shadow Mode (day 1–3): bring up the queue-mode stack beside the single node. Point a test webhook at it. Compare execution outputs for 72 hours before touching production traffic. Zero risk: the old node still owns everything.
- Step 2 — Credential lift (day 3): export credentials from SQLite with the SAME encryption key, import into Postgres. Verify one credential round-trip before proceeding. This is where most migrations die.
- Step 3 — Webhook cutover (day 4): flip DNS/proxy for webhook paths to the webhook processor (port 5679). Legacy acks keep 200-ing from the old node until TTL expires — no client sees a failure.
- Step 4 — Scale workers (day 5): ramp
--scale worker=4, watchbull:jobs:waitdepth and Postgrespg_stat_activityfor 24 h. Add PgBouncer before adding worker #7. - Step 5 — Decommission + sentinel (day 7): stop the single node, keep the SQLite file as cold backup for 30 days, deploy the stuck-execution sentinel cron. Migration complete with zero client-visible errors.
12. Enterprise Architectural FAQ
Download this guide’s assets
Get the full docker-compose.yml + .env template from this guide. Instant download — no email required.
Download the bundle →Prefer it by email? Subscribe for the full library →
Why is Redis superior to Postgres for the queue itself?
How do I size worker count and concurrency for my VPS?
What happens to in-flight executions when I redeploy n8n?
N8N_GRACEFUL_SHUTDOWN_TIMEOUT=120, SIGTERM makes each worker stop pulling new jobs and drain current ones; Bull re-queues anything unfinished after the stalled sweep. Without it, a redeploy mid-run duplicates or orphans executions. Deploy order that doesn't page you: scale workers up first, let old workers drain, then bounce main and webhook last — main owns the UI and the encryption key checks.Is one VPS a single point of failure, and does it matter?
N8N_ENCRYPTION_KEY in a secret store gives a 15-minute rebuild. Multi-host HA (Postgres primary/replica, 3 Redis sentinels) runs ~$75/mo and buys seconds of failover. For most teams snapshot discipline beats replica theater until downtime costs more than $55/mo.13. Related Architecture Benchmarks
Don't want to babysit 7 containers?
The queue-mode stack above is what we run internally — but if your execution volume is still under ~50k/month, a managed visual orchestrator with the same topology removes the ops tax entirely. We benchmarked both paths so you don't have to.
Deploy the Managed Alternative on Make.com →
Clean router link — we may earn a commission at $0 added cost to you. Full disclosure below.
Engineering Transparency: Benchmarks above were run on our own Hetzner CPX31 test rig, 2026-09-08, with wrk as the load generator and median-of-3 methodology; the loss test used docker kill --signal=KILL at 400 concurrency. These are our numbers on our workload (1 KB JSON echo workflows) — binary-heavy or LLM-call workflows will move p99 and RAM figures. Vendor documentation (n8n queue-mode docs, Bull stalled-job semantics, Postgres 16 connection management) is directionally correct but quotes no numbers; that's why we measured. Results vary with your hardware, workflow shape, and upstream API latencies — rerun the reproducing section before quoting anything to your CFO. Some links on this page are affiliate router paths (/links/make.html); if you deploy through them we may earn a commission at zero added cost to you. See our Terms of Service.
Author: Alex, Principal AI Infrastructure Architect — Wenboom Engineering Lab.
Related Cluster Intelligence
- Voiceflow vs Bland.ai: Voice Agent Comparison →
- ElevenLabs Pricing 2026: Plans, Credits, Alternatives →
- Hetzner vs DigitalOcean vs AWS for n8n Docker →
- ActiveCampaign vs HubSpot: Cost at Your Contact Count →
- n8n Webhook Response Relay: Breaking the 64 MiB Limit →
- Make.com 429 Rate Limit Fix: Circuit Breaker Protocol →