n8n queue mode execution moving through Redis and PostgreSQL to a worker
Last edited on August 11, 2026

An n8n queue-mode deployment is ready for production only when a real trigger returns an execution ID that can be joined to worker logs and a terminal database state. A healthy main process, a Redis PONG, and a ready worker prove that three components are alive; they do not prove that one execution crossed the whole handoff.

Use a two-part acceptance test. First, invoke a production webhook while a worker is available and require that worker to finish the returned execution ID. Second, stop the worker, invoke another uniquely tagged request, prove that PostgreSQL records the execution and Redis holds the job, then start a replacement worker and require the same waiting request to complete. In the reproduced n8n 2.33.5 lab below, execution 2 stayed new with bull:jobs:wait present while no worker existed, then the replacement worker finished job 2 and PostgreSQL recorded success.

This is a deployment acceptance guide for developers and operators with permission to start isolated PostgreSQL, Redis, and n8n processes. It uses synthetic credentials, loopback-only listeners, and a marker-guarded disposable path. It does not benchmark capacity or claim that queue mode makes downstream API actions exactly once.

Define the Queue-Mode Acceptance Contract

Current n8n queue-mode documentation assigns distinct ownership: the main instance receives timers and webhooks, Redis carries an execution ID, a worker reads the workflow from the database and performs the work, and the worker writes the result back to the database. Every main and worker therefore needs the same database, Redis connection, n8n version, and encryption key.

That architecture gives the test four observable surfaces:

  1. HTTP: a unique token returns with $execution.id only after the workflow finishes.
  2. PostgreSQL: the execution row moves from a nonterminal state to success.
  3. Redis: a stopped-worker request creates a waiting Bull job.
  4. Worker log: the matching execution and job IDs are claimed and finished by a worker.
Sequence receipt for n8n worker loss and recoveryFive lifelines show a caller, n8n main, PostgreSQL, Redis, and worker. The caller sends a token, PostgreSQL records execution 2 as new, Redis holds job 2 while the worker is stopped, a replacement worker claims the job, PostgreSQL records success, and the same HTTP request returns execution ID 2.Callern8n mainPostgreSQLRedisWorkerGET unique tokenreplacement claims 2execution 2 · newjob 2 · waitexecution 2 · successHTTP id=2 returnsNO WORKER
The reproduced second request remained new in PostgreSQL and in Redis’s wait queue until a replacement worker claimed job 2. This explanatory route maps the selectable receipt below; it is not a substitute for that execution evidence.

Readiness remains useful, but it owns a narrower question. A container can pass its healthcheck while a published webhook is absent, a worker points to a different encryption key, or no worker ever claims the job. When process health itself is uncertain, use a Docker healthcheck and main-process diagnosis first; then return to an end-to-end execution receipt.

Build a Guarded PostgreSQL and Redis Base

Reproduction used Debian 13.6, Node.js 24.18.0, PostgreSQL 17.10, Redis 8.0.2, and pinned n8n 2.33.5. Obtain n8n with npm install --prefix "$PWD/n8n-2.33.5-runtime" https://registry.npmjs.org/n8n/-/n8n-2.33.5.tgz and install matching PostgreSQL and Redis packages through your operating system before starting. The lab expects jq, rg, curl, redis-cli, and PostgreSQL client utilities.

Use an authorized disposable host or isolated VM. If the validation must run remotely, root-access VPS infrastructure can provide control over long-running processes, private listeners, storage, and supervision, but it does not replace the acceptance test. Administrative access initializes this fixture; production n8n main and worker processes should run under a dedicated unprivileged service identity. Do not run the fixture against an existing n8n database.

Begin with a tested input that refuses an unmarked pre-existing path, exposes PostgreSQL and Redis only on loopback, registers shutdown before the n8n processes start, and supplies one synthetic encryption key to every process. Replace RUNTIME only with the directory where the pinned package was installed.

set -Eeuo pipefail

LAB=/var/tmp/voxfor-n8n-queue-lab-123
RUNTIME="$PWD/n8n-2.33.5-runtime"
N8N="$RUNTIME/node_modules/.bin/n8n"
PG_BIN=/usr/lib/postgresql/17/bin
PG_PORT=55433
REDIS_PORT=16379
N8N_PORT=15678
PG_PASSWORD=voxfor_n8n_lab_pg_123

[[ -x "$N8N" ]]
if [[ -e "$LAB" ]]; then
  [[ -f "$LAB/.voxfor-n8n-queue-lab" ]]
  exit 2
fi
install -d -m 0755 "$LAB"
touch "$LAB/.voxfor-n8n-queue-lab"
install -d -m 0750 "$LAB/receipts" "$LAB/logs" "$LAB/n8n-user" "$LAB/pgsocket"
install -d -m 0700 -o postgres -g postgres "$LAB/pgdata"
chown postgres:postgres "$LAB/pgsocket" "$LAB/logs"
printf '%s\n' "$PG_PASSWORD" >"$LAB/pg-password"
chmod 0600 "$LAB/pg-password"
chown postgres:postgres "$LAB/pg-password"

stop_pidfile() {
  local file="$1" pid
  [[ -s "$file" ]] || return 0
  pid="$(<"$file")"
  [[ "$pid" =~ ^[0-9]+$ ]] || return 1
  kill "$pid" 2>/dev/null || true
  for _ in {1..30}; do
    kill -0 "$pid" 2>/dev/null || return 0
    sleep 0.2
  done
  kill -KILL "$pid" 2>/dev/null || true
}

cleanup_services() {
  [[ "$LAB" == /var/tmp/voxfor-n8n-queue-lab-123 ]]
  [[ -f "$LAB/.voxfor-n8n-queue-lab" ]] || return 0
  stop_pidfile "$LAB/main.pid"
  stop_pidfile "$LAB/worker.pid"
  stop_pidfile "$LAB/queued-curl.pid"
  redis-cli -h 127.0.0.1 -p "$REDIS_PORT" shutdown nosave >/dev/null 2>&1 || true
  runuser -u postgres -- "$PG_BIN/pg_ctl" -D "$LAB/pgdata" -m fast stop >/dev/null 2>&1 || true
}
trap cleanup_services EXIT INT TERM

runuser -u postgres -- "$PG_BIN/initdb" -D "$LAB/pgdata" \
  --username=postgres --pwfile="$LAB/pg-password" \
  --auth-host=scram-sha-256 --auth-local=trust >/dev/null
runuser -u postgres -- "$PG_BIN/pg_ctl" -D "$LAB/pgdata" \
  -l "$LAB/logs/postgres.log" \
  -o "-h 127.0.0.1 -p $PG_PORT -k $LAB/pgsocket" start >/dev/null
for _ in {1..60}; do
  PGPASSWORD="$PG_PASSWORD" pg_isready -h 127.0.0.1 -p "$PG_PORT" -U postgres >/dev/null 2>&1 && break
  sleep 0.2
done
PGPASSWORD="$PG_PASSWORD" pg_isready -h 127.0.0.1 -p "$PG_PORT" -U postgres >/dev/null
PGPASSWORD="$PG_PASSWORD" createdb -h 127.0.0.1 -p "$PG_PORT" -U postgres n8nlab

redis-server --bind 127.0.0.1 --port "$REDIS_PORT" --save '' \
  --appendonly no --dir "$LAB" --pidfile "$LAB/redis.pid" \
  --logfile "$LAB/logs/redis.log" --daemonize yes
for _ in {1..60}; do
  redis-cli -h 127.0.0.1 -p "$REDIS_PORT" ping 2>/dev/null | grep -qx PONG && break
  sleep 0.2
done
redis-cli -h 127.0.0.1 -p "$REDIS_PORT" ping | grep -qx PONG

export DB_TYPE=postgresdb
export DB_POSTGRESDB_HOST=127.0.0.1 DB_POSTGRESDB_PORT="$PG_PORT"
export DB_POSTGRESDB_DATABASE=n8nlab DB_POSTGRESDB_USER=postgres
export DB_POSTGRESDB_PASSWORD="$PG_PASSWORD" DB_POSTGRESDB_SCHEMA=public
export EXECUTIONS_MODE=queue
export QUEUE_BULL_REDIS_HOST=127.0.0.1 QUEUE_BULL_REDIS_PORT="$REDIS_PORT"
export QUEUE_HEALTH_CHECK_ACTIVE=true
export N8N_CONCURRENCY_PRODUCTION_LIMIT=-1
export N8N_ENCRYPTION_KEY=voxfor_queue_lab_synthetic_encryption_key_123456
export N8N_USER_FOLDER="$LAB/n8n-user"
export N8N_PORT N8N_LISTEN_ADDRESS=127.0.0.1 N8N_PROTOCOL=http N8N_HOST=127.0.0.1
export N8N_WEBHOOK_URL="http://127.0.0.1:$N8N_PORT/" N8N_SECURE_COOKIE=false
export N8N_DIAGNOSTICS_ENABLED=false N8N_PERSONALIZATION_ENABLED=false
export N8N_VERSION_NOTIFICATIONS_ENABLED=false N8N_LOG_LEVEL=debug
export EXECUTIONS_DATA_SAVE_ON_SUCCESS=all EXECUTIONS_DATA_SAVE_ON_ERROR=all

PostgreSQL is deliberate here. Current n8n guidance recommends against SQLite in queue mode. Its process-local design does not provide the shared database boundary that multiple instances need.

Publish One Workflow That Returns Its Execution ID

n8n-queue-workflow-publish creates a two-node production webhook. Its response echoes a caller-supplied token, n8n’s own execution ID, and a fixed completion marker. That marker is not proof by itself; the worker and database evidence must agree with it.

cat >"$LAB/workflow.json" <<'JSON'
[{"id":"voxfor123queue","name":"Voxfor Queue Worker Handoff Proof","active":true,
"versionId":"12345678-1234-4234-8234-123456789abc","nodes":[
 {"parameters":{"httpMethod":"GET","path":"voxfor-queue-handoff-123","responseMode":"lastNode","options":{}},
  "id":"12345678-1234-4234-8234-123456789001","name":"Receive proof token",
  "type":"n8n-nodes-base.webhook","typeVersion":2.1,"position":[0,0],
  "webhookId":"12345678-1234-4234-8234-123456789002"},
 {"parameters":{"assignments":{"assignments":[
   {"id":"12345678-1234-4234-8234-123456789003","name":"token","value":"={{ $json.query.token }}","type":"string"},
   {"id":"12345678-1234-4234-8234-123456789004","name":"execution_id","value":"={{ $execution.id }}","type":"string"},
   {"id":"12345678-1234-4234-8234-123456789005","name":"queue_receipt","value":"worker-completed","type":"string"}]},"options":{}},
  "id":"12345678-1234-4234-8234-123456789006","name":"Return execution receipt",
  "type":"n8n-nodes-base.set","typeVersion":3.4,"position":[280,0]}],
"connections":{"Receive proof token":{"main":[[{"node":"Return execution receipt","type":"main","index":0}]]}},
"settings":{"executionOrder":"v1"},"staticData":null,"pinData":{},"tags":[]}]
JSON

"$N8N" import:workflow --input="$LAB/workflow.json" --activeState=fromJson
"$N8N" publish:workflow --id=voxfor123queue

Importing and publishing before the main starts makes the production webhook part of startup activation. A successful CLI message still is not the acceptance state; wait until the main log explicitly records this workflow as activated.

Prove the Normal Main-to-Worker Handoff

On a single host, the embedded task brokers need different loopback ports. Separate containers have their own network namespaces and normally do not need these lab-only port overrides. The shared lab configuration explicitly sets N8N_CONCURRENCY_PRODUCTION_LIMIT=-1, so the worker flag remains authoritative. The third input starts one main and one worker with --concurrency=1 only to serialize this deterministic acceptance test, waits for workflow activation rather than only /healthz, invokes the webhook, and checks the JSON contract. Current n8n guidance recommends worker concurrency of at least 5 in production; 1 is not a sizing recommendation.

N8N_RUNNERS_BROKER_PORT=15679 "$N8N" start >"$LAB/logs/main.log" 2>&1 &
echo $! >"$LAB/main.pid"
N8N_RUNNERS_BROKER_PORT=15680 "$N8N" worker --concurrency=1 \
  >"$LAB/logs/worker.log" 2>&1 &
echo $! >"$LAB/worker.pid"

for _ in {1..120}; do
  rg -q 'Activated workflow "Voxfor Queue Worker Handoff Proof"' "$LAB/logs/main.log" && break
  sleep 0.5
done
rg -q 'Activated workflow "Voxfor Queue Worker Handoff Proof"' "$LAB/logs/main.log"

for _ in {1..120}; do
  rg -q 'n8n worker is now ready' "$LAB/logs/worker.log" && break
  sleep 0.5
done
rg -q 'n8n worker is now ready' "$LAB/logs/worker.log"

curl -fsS --max-time 30 \
  "http://127.0.0.1:$N8N_PORT/webhook/voxfor-queue-handoff-123?token=baseline-123" \
  >"$LAB/receipts/baseline-response.json"
[[ -s "$LAB/receipts/baseline-response.json" ]]
jq -e '.token=="baseline-123" and .queue_receipt=="worker-completed" and (.execution_id|length)>0' \
  "$LAB/receipts/baseline-response.json" >/dev/null
rg 'Worker finished execution 1 \(job 1\)' "$LAB/logs/worker.log"

Execution ID 1 returned at baseline, and the worker logged Worker finished execution 1 (job 1) with success: true. This proves a healthy path once. It does not prove what happens when worker capacity is absent.

Hold a Request While No Worker Exists

Next, stop only the worker, send a different token in a background request, and inspect state while that request is still waiting. The caller’s 30-second timeout is long enough for this small controlled interruption; choose a production probe timeout that matches your proxy and webhook budgets.

stop_pidfile "$LAB/worker.pid"
: >"$LAB/worker.pid"

curl -fsS --max-time 30 \
  "http://127.0.0.1:$N8N_PORT/webhook/voxfor-queue-handoff-123?token=queued-while-worker-stopped" \
  >"$LAB/receipts/queued-response.json" 2>"$LAB/receipts/queued-curl.stderr" &
echo $! >"$LAB/queued-curl.pid"
sleep 2

PGPASSWORD="$PG_PASSWORD" psql -h 127.0.0.1 -p "$PG_PORT" -U postgres -d n8nlab -Atc \
  'select id,status,"workflowId","waitTill" is null from execution_entity order by id desc limit 3;'
redis-cli -h 127.0.0.1 -p "$REDIS_PORT" --scan --pattern 'bull:*' | sort

At that moment, PostgreSQL returned 2|new|voxfor123queue|t above the already successful execution 1. Redis exposed bull:jobs:2 and bull:jobs:wait. The response file remained empty because the workflow had not executed. Those three observations distinguish a preserved waiting job from a lost trigger or a falsely successful request.

Start a Replacement Worker and Close the Same Request

Do not send a third request to claim recovery. The useful proof is that a replacement worker completes the already waiting execution. The fifth input starts that worker with the same configuration, waits on the original curl process, validates its token and execution ID, then queries terminal database state.

N8N_RUNNERS_BROKER_PORT=15680 "$N8N" worker --concurrency=1 \
  >"$LAB/logs/worker-restarted.log" 2>&1 &
echo $! >"$LAB/worker.pid"

wait "$(<"$LAB/queued-curl.pid")"
: >"$LAB/queued-curl.pid"
[[ -s "$LAB/receipts/queued-response.json" ]]
jq -e '.token=="queued-while-worker-stopped" and .queue_receipt=="worker-completed" and (.execution_id|length)>0' \
  "$LAB/receipts/queued-response.json" >/dev/null

PGPASSWORD="$PG_PASSWORD" psql -h 127.0.0.1 -p "$PG_PORT" -U postgres -d n8nlab -Atc \
  'select id,status,"workflowId","startedAt" is not null,"stoppedAt" is not null from execution_entity order by id;'
rg 'Worker finished execution 2 \(job 2\)' "$LAB/logs/worker-restarted.log"

Here is the full secret-free receipt from the final rerun:

environment n8n=2.33.5 postgresql=17.10 redis=8.0.2 listeners=loopback
baseline={"token":"baseline-123","execution_id":"1","queue_receipt":"worker-completed"}
worker_baseline="Worker finished execution 1 (job 1)" success=true
worker_stopped_postgresql=2|new|voxfor123queue|t
worker_stopped_redis=bull:jobs:2,bull:jobs:id,bull:jobs:priority,bull:jobs:stalled-check,bull:jobs:wait
queued_after_restart={"token":"queued-while-worker-stopped","execution_id":"2","queue_receipt":"worker-completed"}
worker_replacement="Worker finished execution 2 (job 2)" success=true
final_postgresql=1|success|voxfor123queue|t|t;2|success|voxfor123queue|t|t
verification=accepted
cleanup=services_stopped_guarded_lab_removed

Accept the lab only when the baseline token returns a nonempty execution ID and the first worker finishes that ID; the stopped-worker request creates a new PostgreSQL row and a Redis wait key without returning early; the replacement worker finishes the same second execution; both rows end in success with start and stop timestamps; PostgreSQL and Redis stop; and the exact marker-guarded lab path is absent. A readiness-only result is rejected.

Translate the Receipt Into Production Controls

Keep state and encryption identical

Pin the same n8n release across the main, webhook processors, and workers. Use one managed encryption key from a secret store; never let each replica generate its own. Back up the PostgreSQL database and encryption key as one recovery dependency. Redis transports the queue, but workers still fetch workflow data and persist execution results through PostgreSQL.

Queue mode also changes binary-data planning. The official queue guide warns that filesystem binary storage is not supported because workers do not share one local filesystem. Select a supported shared external-storage design for workflows that handle files, then test a real binary workflow separately; this JSON-only receipt cannot validate it.

Separate worker count from concurrency

n8n worker --concurrency=N controls how many jobs one worker can process concurrently only when N8N_CONCURRENCY_PRODUCTION_LIMIT is unset or -1. Any other configured global production limit overrides the worker flag. Adding another worker adds another process and failure domain. The current n8n concurrency documentation recommends worker concurrency of at least 5; do not turn the lab’s deliberately serialized 1 into a capacity recommendation.

Measure queue wait time, execution duration, PostgreSQL connections, Redis latency, CPU, memory, and downstream rate limits under a representative workflow mix. If Redis pauses during persistence work, follow a Redis fork-latency investigation before adding workers. If a worker vanishes under memory pressure, retain kernel and cgroup evidence with the Linux OOM-kill evidence workflow rather than treating every disappearance as a queue defect.

Preserve the failure boundary outside n8n

This stopped-worker test proves that one queued execution survived a controlled worker absence. It does not make a third-party charge, email, ticket, or provisioning call exactly once. A worker can fail after the external side effect succeeds but before n8n persists completion. Use stable operation keys and the idempotency and action-reconciliation pattern for consequential actions.

Package the same contract in containers only after the process-level proof is understood. Voxfor’s Docker Compose deployment basics explains service grouping and lifecycle, while the acceptance criteria above remain unchanged: a green container is not the same as a completed execution ID.

FAQ: n8n Queue Mode Acceptance

How do I know an n8n execution ran on a worker?

Trigger a production workflow that returns $execution.id, then find that exact ID in the worker’s started and finished log lines and in PostgreSQL’s terminal execution state. A workflow response without matching worker ownership is not enough for queue-mode acceptance.

Why is my n8n main healthy while a webhook stays open?

The main can accept and queue a production trigger while no worker is available. Inspect the execution row, Redis wait state, worker registry/readiness, and worker logs. Do not restart or resend blindly before determining whether the original job still exists.

Must every n8n worker use the same encryption key?

Yes. Main and worker instances need the same N8N_ENCRYPTION_KEY so credentials stored by one instance can be decrypted by another. Manage it as a secret and restore it with the database; never copy the synthetic lab value into production.

Can I use SQLite for n8n queue mode?

Current n8n guidance recommends PostgreSQL for queue mode and does not recommend the SQLite setup for distributed main and worker processes. All instances need consistent shared workflow and execution state.

What worker concurrency should I choose?

There is no universal value. n8n currently recommends at least 5 per worker, but a configured N8N_CONCURRENCY_PRODUCTION_LIMIT other than -1 overrides the --concurrency flag. Start from workflow memory, CPU, database connections, external rate limits, and execution duration, then load-test the real mix. Worker concurrency and the number of worker replicas are separate controls and should be changed independently.

Does queue mode guarantee exactly-once external actions?

No. Queue recovery can cause a workflow action to be retried around a worker or network failure. Use idempotency keys, durable action records, and reconciliation for operations such as billing, account creation, or infrastructure changes.

Should production webhooks go through the main process?

n8n supports dedicated webhook processors in scaled deployments. Route production webhook traffic according to the current official topology, but keep editor/UI traffic away from the webhook pool and repeat the same execution-ID handoff test through the actual proxy path.

Remove Only the Disposable Lab

Rollback for this fixture stops only PIDs recorded inside the marked lab, shuts down Redis on loopback port 16379, stops the PostgreSQL cluster under the guarded data directory, removes exactly /var/tmp/voxfor-n8n-queue-lab-123, and proves those listeners and that path are gone. In production, rollback means draining trigger traffic, preserving PostgreSQL and the encryption key, returning the prior pinned n8n release and worker configuration, and reconciling nonterminal executions before resuming; deleting a live queue is not rollback.

cleanup_services
[[ ! -S "$LAB/pgsocket/.s.PGSQL.$PG_PORT" ]]
! redis-cli -h 127.0.0.1 -p "$REDIS_PORT" ping >/dev/null 2>&1
[[ "$LAB" == /var/tmp/voxfor-n8n-queue-lab-123 ]]
[[ -f "$LAB/.voxfor-n8n-queue-lab" ]]
rm -rf -- "$LAB"
[[ ! -e "$LAB" ]]
trap - EXIT INT TERM
printf 'cleanup=services_stopped_guarded_lab_removed\n'

Keep the receipt, not the lab: pinned versions, workflow ID, unique request token, execution ID, stopped-worker database state, Redis wait keys, worker completion line, final database state, verification result, and cleanup result. That evidence lets the next deployment answer a concrete question—did this production trigger survive the queue and finish on a worker?

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *