Back to Article List

n8n queue mode with Redis and workers: Docker Compose setup

n8n queue mode with Redis and workers: Docker Compose setup - n8n queue mode with Redis and workers: Docker Compose setup

Queue mode splits n8n into a main process that owns the editor, the schedules and the webhooks plus one or more worker processes that run the executions, with Redis sitting between them as the job queue. Postgres stays the only place workflows, credentials and execution records live; Redis holds a job while it waits and while a worker has it, nothing else. On a single VPS the whole thing fits in one Docker Compose file, and that file is what this guide builds: Postgres 17, Redis 7, an n8n main and a worker service you can scale to two or three copies. Sizing the workers, adding webhook processors and growing past one server are a different topic, covered in the n8n worker and Redis sizing guide, so I keep them to a few sentences here.

What changes when EXECUTIONS_MODE is queue

In regular mode one process does everything. Set EXECUTIONS_MODE=queue and the main process stops running production executions itself: it writes a job into Redis, a worker picks the job up, runs the workflow, writes the result to Postgres and reports back. The editor stays responsive during a burst of two hundred webhooks because the main is only enqueueing them, and a worker that crashes takes its in-flight executions with it and nothing else. The trade is one more moving part (Redis) and a database that has to be Postgres, because the n8n queue mode docs say plainly that queue mode with SQLite isn't recommended. Still on SQLite? The PostgreSQL vs SQLite for n8n comparison has the migration steps, and you want those done before anything below.

The BULL in the variable names is the Bull queue library n8n uses on top of Redis, which is also why the Redis keys you'll see later start with bull:. That's the extent of the trivia.

Prerequisites for queue mode on one VPS

Docker Engine with the compose plugin on Ubuntu 24.04, a domain pointed at the server and a reverse proxy in front of port 5678. If the server is fresh, the n8n install guide for Ubuntu 24.04 gets you to a working single-process instance with Caddy, and the compose below replaces that file. Redis needs to be version 6 or newer if you plan to use ACL usernames with QUEUE_BULL_REDIS_USERNAME; I use a plain requirepass password and the redis:7 image. Memory: a main plus two workers plus Postgres and Redis idles at around 1.5 GB on my box, so 4 GB is the sensible floor and 2 vCPU is enough to start.

Docker Compose file for n8n queue mode

Everything below goes in one compose.yaml. The shared environment block is a YAML anchor so the main and the workers can't drift apart, which is where most "it works on main but the worker fails" reports come from.

x-n8n-env: &n8n-env
  EXECUTIONS_MODE: queue
  N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
  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_BULL_REDIS_HOST: redis
  QUEUE_BULL_REDIS_PORT: "6379"
  QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}
  GENERIC_TIMEZONE: Europe/London
  TZ: Europe/London

services:
  postgres:
    image: postgres:17
    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 -d n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --maxmemory-policy noeviction
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n-main:
    image: n8nio/n8n:2.38.5
    restart: unless-stopped
    environment:
      <<: *n8n-env
      N8N_HOST: n8n.example.com
      N8N_PROTOCOL: https
      N8N_PORT: "5678"
      N8N_EDITOR_BASE_URL: https://n8n.example.com/
      N8N_WEBHOOK_URL: https://n8n.example.com/
      N8N_PROXY_HOPS: "1"
      N8N_METRICS: "true"
      N8N_METRICS_INCLUDE_QUEUE_METRICS: "true"
    ports:
      - "127.0.0.1:5678:5678"
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  n8n-worker:
    image: n8nio/n8n:2.38.5
    restart: unless-stopped
    command: worker --concurrency=10
    environment:
      <<: *n8n-env
      QUEUE_HEALTH_CHECK_ACTIVE: "true"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      n8n-main:
        condition: service_started

volumes:
  postgres_data:
  redis_data:
  n8n_data:

And the .env next to it:

N8N_ENCRYPTION_KEY=paste-the-output-of-openssl-rand-hex-32
POSTGRES_PASSWORD=long-random-string
REDIS_PASSWORD=another-long-random-string

Generate the key with openssl rand -hex 32 before the first start. If you're converting an existing instance, take the key it already has from /home/node/.n8n/config inside the container and put that in .env instead, or every credential in the database becomes unreadable on the workers.

The shared environment block

Every n8n service in queue mode needs the same four groups of settings: EXECUTIONS_MODE=queue, the encryption key, the Postgres connection and the Redis connection. The anchor gives all of that to the main and to every worker in one place. The main then adds the public-facing variables, the host, protocol, editor URL and N8N_WEBHOOK_URL (2.35 renamed it from WEBHOOK_URL; the old name is a deprecated alias that logs a warning at startup), plus N8N_PROXY_HOPS=1 because Caddy or nginx sits in front. Workers don't build URLs and don't serve the UI, so they get none of that.

The image tag is pinned to 2.38.5 on both services on purpose. Main and workers have to run the same version, so when you upgrade you change the tag in two places, or better, move it into .env as N8N_IMAGE and reference it once.

Worker command and concurrency

The image's entrypoint accepts worker as its argument, so command: worker --concurrency=10 runs n8n worker with ten parallel executions per container. Ten is also the default, and n8n's docs recommend not going below 5. On a 4 vCPU VPS I run two workers at 5 where one at 10 would give the same capacity, because a worker restart then only drags half the in-flight capacity down with it, and because two containers make it obvious in docker stats when one workflow is eating all the CPU.

Workers get no volume in this file. In queue mode binary data defaults to the database and the encryption key comes from the environment, so nothing in /home/node/.n8n on a worker is worth keeping. The one exception is community nodes: n8n installs them into the user folder at startup, so if you rely on them, give each worker its own named volume, never the main's.

Redis persistence

The --appendonly yes flag turns on the append-only file, and the Redis persistence docs put the loss window for the default everysec policy at one second of writes. With snapshots only you'd lose the last few minutes of queued jobs on a crash. The queue is your pending work, which is why I don't run it without AOF. --maxmemory-policy noeviction is there because a queue that silently evicts keys under memory pressure loses executions with no error anywhere; with noeviction Redis refuses the write instead and n8n logs it. The redis_data volume holds the AOF so a container recreate keeps the queue.

Start the stack and scale workers

docker compose up -d
docker compose logs -f n8n-main n8n-worker

The main runs the database migrations, so the worker log will show Redis connection lines and then wait. Open the editor through your proxy, create the owner account if this is a fresh instance and run any workflow. The execution should appear in the executions list with the worker having done the work, which you can confirm in the worker log.

Two workers:

docker compose up -d --scale n8n-worker=2

This works because the worker service publishes no host port. If you ever add a ports: entry to the worker, the second copy fails to start with a port conflict. To make two the permanent default, add scale: 2 under the service.

The first time I did this on a second box I copied the compose file but not the .env, the worker generated its own random encryption key at startup and every execution that touched a credential failed with a decryption error while the main ran fine. Twenty minutes of staring at Redis before I checked the obvious thing.. The key is the first thing to compare when a worker fails and the main doesn't.

Binary data in queue mode

N8N_DEFAULT_BINARY_DATA_MODE has three values in n8n 2.x: filesystem, database and s3 (plus azure). In queue mode the default is database, and it's the one to keep on a single VPS. Filesystem mode isn't supported in queue mode at all, since a worker writing a file to its own disk gives the main nothing to read. S3 and Azure storage need a Business or Enterprise license, and n8n refuses to start in s3 mode without one. N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE caps a single binary row at 512 MiB by default.

Manual executions and OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS

By default a manual run from the editor still executes on the main process, even in queue mode, so a big test run can make the editor stutter the same way it did before. Set OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true on the main to send those to the queue too. I leave it off: I want a test run to fail fast in front of me, and a queue with a backlog would put my manual run behind it.

Task runners on workers

Code nodes run on task runners in 2.x, and in the default internal mode each worker starts its own runner as a child process, so the compose above needs nothing extra for JavaScript. If you switch to external mode for isolation or for Python, the task runner setup docs require a separate n8nio/runners sidecar per worker, on a matching image version. That breaks the --scale trick, because each sidecar needs the URI of its own worker's broker, so you define n8n-worker-1 with n8n-runner-1, n8n-worker-2 with n8n-runner-2 and so on, each pair sharing an auth token and each worker with N8N_RUNNERS_MODE=external and the broker listen address opened up to 0.0.0.0. Start with internal mode, move to sidecars when you have a reason.

Verify queue mode with /healthz and /metrics

The worker healthcheck endpoint is off unless QUEUE_HEALTH_CHECK_ACTIVE=true, which the compose sets. It answers on port 5678 inside the worker container:

docker compose exec n8n-worker wget -qO- http://127.0.0.1:5678/healthz
docker compose exec n8n-worker wget -qO- http://127.0.0.1:5678/healthz/readiness

The first returns 200 when the process is up, the second only once the database connection is there. On the main, N8N_METRICS=true with N8N_METRICS_INCLUDE_QUEUE_METRICS=true exposes the queue gauges on /metrics, and the one to watch is n8n_scaling_mode_queue_jobs_waiting:

curl -s http://127.0.0.1:5678/metrics | grep n8n_scaling_mode_queue_jobs

You get active, completed, failed and waiting. On the instance that runs our WHMCS automations, waiting sits at 0 nearly all day and spikes to 3 or 4 when an invoice batch lands, and that number staying above zero for minutes is the signal to add a worker. Keep /metrics behind the proxy or on the loopback port only; the n8n Prometheus and Grafana guide covers scraping it and the other queue flags. If you want to see the raw queue, docker compose exec redis redis-cli -a yourpassword --no-auth-warning KEYS 'bull:*' lists the Bull keys, though on a healthy idle instance there is little to see.

Something I haven't tested: QUEUE_BULL_REDIS_USERNAME with a restricted ACL user. Bull uses a fair number of Redis commands and I don't know which ones a locked-down ACL has to allow for n8n to run cleanly, so my instances use the default user with a password.

Webhook processors and multi-main

n8n webhook starts a process that only receives production webhooks and enqueues them, which lets you take that traffic off the main once volume grows; it needs the same environment block as a worker and a load balancer rule sending /webhook/ paths to it. Multi-main, where two main processes share the editor load, needs an Enterprise license, and the Community edition is one main plus as many workers and webhook processors as you like. Both are laid out in the guide on high availability for n8n.

Converting the one-click template to queue mode

If your instance came from the n8n VPS template, which deploys with Docker and Caddy, it runs as a single process. Stop it, export the encryption key from the running container's config file, write it into .env, drop the compose above in with your domain in place of n8n.example.com and point Caddy at n8n-main:5678. If the template instance is still on SQLite, do the Postgres migration first, since queue mode won't take it.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.