{"id":2953,"date":"2026-11-05T09:52:15","date_gmt":"2026-11-05T09:52:15","guid":{"rendered":"https:\/\/hostry.com\/blog\/?p=2953"},"modified":"2026-11-05T09:52:17","modified_gmt":"2026-11-05T09:52:17","slug":"n8n-queue-mode-workers-not-picking-up-jobs","status":"publish","type":"post","link":"https:\/\/hostry.com\/blog\/n8n-queue-mode-workers-not-picking-up-jobs\/","title":{"rendered":"n8n Queue Mode Workers Not Picking Up Jobs: Fixes and Sizing on a VPS"},"content":{"rendered":"\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"675\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover.webp\" alt=\"n8n queue mode workers not picking up jobs: executions waiting in a Redis queue while idle workers sit next to Postgres on a VPS\" class=\"wp-image-2947\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover-300x169.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover-1024x576.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover-450x253.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-cover-768x432.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<pre class=\"wp-block-preformatted\" style=\"font-size:14px\"><em><strong>n8n queue mode workers not picking up jobs, and executions stuck in Queued, usually mean that main and the workers see a different Redis, encryption key or database. Run the six checks below in order, from queue mode on main through worker health, Redis, key and Postgres; the last one, concurrency, explains jobs that start but run one at a time.<\/strong><\/em><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The commands assume Docker Compose on a <strong><a href=\"https:\/\/hostry.com\/products\/vps\/\">Hostry KVM VPS<\/a><\/strong> or any server where you have root, with Postgres and Redis on the Compose network only, and n8n 2.46.6, the <span class=\"highlighted\">stable<\/span> tag on November 5, 2026.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Workers not picking up jobs: checks in order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run everything next to <span class=\"highlighted\">compose.yaml<\/span>; service names match the file further down, and log lines are quoted from the 2.46.6 source. This helper prints a container&#8217;s environment from the host, so no tools in the image are needed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>envof() { docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"$(docker compose ps -q \"$1\")\"; }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>1. Confirm that main enqueues the execution<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Test with a production execution, such as a webhook call. In 2.x, editor runs stay on main unless <span class=\"highlighted\">OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true<\/span>, so a silent worker during a manual test is normal. Then look at main:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>envof n8n | grep '^EXECUTIONS_MODE'\ndocker compose logs n8n | grep 'Enqueued execution'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In queue mode, main logs <code>Enqueued execution &lt;id&gt; (job &lt;id&gt;)<\/code> for every production run it hands over. If the variable is missing or the log never shows an Enqueued line, main is executing workflows in its own process and nothing reaches Redis. The <span class=\"highlighted\">worker<\/span> command forces its own process into queue mode, so main&#8217;s setting is the one that decides; keep it on both, as the docs ask.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>2. Check that the worker is running and ready<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose ps\ndocker compose logs --tail=40 n8n-worker-1\ndocker compose exec n8n-worker-1 node -e \"fetch('http:\/\/127.0.0.1:5678\/healthz\/readiness').then(r=&gt;console.log(r.status))\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A worker that started cleanly logs <code>n8n worker is now ready<\/code>, followed by its Version, Concurrency, Pool and Queue; the queue should read <span class=\"highlighted\">jobs<\/span>. The readiness call answers 200 only when both the database and Redis connections are up, and 503 otherwise. It needs <span class=\"highlighted\">QUEUE_HEALTH_CHECK_ACTIVE=true<\/span>; without it, and with metrics off, the worker opens no HTTP server at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If <span class=\"highlighted\">docker compose ps<\/span> shows the worker restarting, the end of its log names the reason. A Redis problem shows as <code>Unable to connect to Redis after trying to connect for 10s<\/code> and then <code>Exiting process due to Redis connection error<\/code>; the 10 seconds are <span class=\"highlighted\">QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD<\/span> (10000 ms). A worker with no key at all refuses to start with <code>Failed to start worker because of missing encryption key.<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>3. Compare Redis and database settings across containers<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><span class=\"highlighted\">QUEUE_BULL_REDIS_HOST<\/span> defaults to <span class=\"highlighted\">localhost<\/span>, and inside a container that means the container itself. A worker without the variable reaches no Redis and restarts as in check 2. The quieter failure is a worker on the right server with a different <span class=\"highlighted\">QUEUE_BULL_REDIS_DB<\/span> (default 0) or <span class=\"highlighted\">QUEUE_BULL_PREFIX<\/span> (default <span class=\"highlighted\">bull<\/span> in the source). It starts cleanly and waits on a queue nobody writes to, without an error.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>for s in n8n n8n-worker-1; do\n  printf '%-14s ' \"$s\"\n  envof \"$s\" | grep -E '^(EXECUTIONS_MODE|QUEUE_BULL_|DB_|N8N_ENCRYPTION_KEY)' | sort | sha256sum\ndone\ndocker compose exec redis redis-cli LLEN bull:jobs:wait\ndocker compose exec redis redis-cli LLEN bull:jobs:active<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Matching hashes mean main and worker agree, with no secret printed. Otherwise, compare the names alone with <span class=\"highlighted\">envof n8n-worker-1 | grep -E &#8216;^(QUEUE_BULL_|DB_)&#8217; | cut -d= -f1<\/span>, then read those values on both sides in your own terminal, such as <span class=\"highlighted\">envof n8n | grep &#8216;^QUEUE_BULL_REDIS_DB&#8217;<\/span>. Fix <span class=\"highlighted\">.env<\/span> and run <span class=\"highlighted\">docker compose up -d<\/span>, which per the Docker docs recreates containers whose configuration changed, keeping volumes. Then repeat check 1.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A <span class=\"highlighted\">wait<\/span> count that keeps growing while <span class=\"highlighted\">active<\/span> stays at 0 means jobs reach Redis and no worker consumes them from that database and prefix.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>4. Use one N8N_ENCRYPTION_KEY everywhere<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the worker&#8217;s key differs from main&#8217;s, jobs are picked up and then fail with <code>Credentials could not be decrypted. The likely reason is that a different \"encryptionKey\" was used to encrypt the data.<\/code> A key also lives in <span class=\"highlighted\">\/home\/node\/.n8n\/config<\/span>, where main stores a random one on first start if <span class=\"highlighted\">N8N_ENCRYPTION_KEY<\/span> was never set. Adding the variable later with a fresh value makes n8n stop with <code>Mismatching encryption keys<\/code>. Read the existing key out instead:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose exec n8n node -e \"console.log(JSON.parse(require('fs').readFileSync('\/home\/node\/.n8n\/config','utf8')).encryptionKey)\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Put that value in <span class=\"highlighted\">.env<\/span> for main and every worker, and store a copy away from the server; a database restored without it has every credential unreadable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>5. Point main and workers at the same Postgres database<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A worker without <span class=\"highlighted\">DB_TYPE=postgresdb<\/span> falls back to a private SQLite file, which the docs do not support for distributed setups. At startup it warns <code>Scaling mode is not officially supported with sqlite. Please use PostgreSQL instead.<\/code> Later it dequeues jobs it cannot find and logs <code>Worker failed to find data for execution &lt;id&gt; (job &lt;id&gt;)<\/code>. A different <span class=\"highlighted\">DB_POSTGRESDB_DATABASE<\/span> or <span class=\"highlighted\">DB_POSTGRESDB_SCHEMA<\/span> on the worker ends the same way; the hash loop catches both.<\/p>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">Before you touch concurrency, check that every container sees the same Redis database and the same encryption key. A worker waiting on the wrong queue logs nothing, and that silence costs more time than any error message.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>6. Find out what caps concurrency<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><span class=\"highlighted\">N8N_CONCURRENCY_PRODUCTION_LIMIT<\/span> also governs queue mode, per the concurrency control docs; on a worker, any value other than -1 replaces the <span class=\"highlighted\">&#8211;concurrency<\/span> flag. A leftover limit of 1 makes each worker take one job at a time while the rest wait as Queued. The ready banner prints the effective number, and below 5 the worker adds <code>Concurrency is set to less than 5. THIS CAN LEAD TO AN UNSTABLE ENVIRONMENT.<\/code><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Executions stuck in Queued or Running after a worker dies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bull leases each job to one worker. In the <strong><a href=\"https:\/\/docs.n8n.io\/hosting\/configuration\/environment-variables\/queue-mode\/\" target=\"_blank\" rel=\"noopener\">queue mode environment variables reference<\/a><\/strong>, <span class=\"highlighted\">QUEUE_WORKER_LOCK_DURATION<\/span> sets that lease to 60000 ms, renewed every 10000 ms, and <span class=\"highlighted\">QUEUE_WORKER_STALLED_INTERVAL<\/span> looks for lapsed leases every 30000 ms. A worker killed mid-run stops renewing, and its jobs count as stalled within about 90 seconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">n8n 2.0 dropped the automatic retry of stalled jobs (<span class=\"highlighted\">QUEUE_WORKER_MAX_STALLED_COUNT<\/span> has no effect), so the execution never runs again on its own, though Postgres may still list it as running. Main&#8217;s queue recovery check marks it crashed once Redis no longer holds the job, every 180 minutes in the source (<span class=\"highlighted\">N8N_EXECUTIONS_QUEUE_RECOVERY_INTERVAL<\/span>, in minutes), so a dead run can show Running for hours.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker inspect --format '{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}' $(docker compose ps -q)\nsudo journalctl -k --since today | grep -i -E 'oom|killed process'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first line flags containers that hit their own limit; the kernel log shows kills when the whole VPS ran out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After a Redis restart without persistence, waiting jobs vanish while their executions stay in Postgres until the recovery check. The compose file below turns on AOF with <span class=\"highlighted\">&#8211;appendonly yes<\/span> on a volume.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Give workers time to stop<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On SIGTERM, n8n waits <span class=\"highlighted\">N8N_GRACEFUL_SHUTDOWN_TIMEOUT<\/span> (30 seconds by default) for running executions. <span class=\"highlighted\">docker stop<\/span> sends SIGKILL after 10 seconds unless the service sets <span class=\"highlighted\">stop_grace_period<\/span>, so updates would kill active jobs. The file below allows 45 seconds; longer workflows need a longer timeout on both sides.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Find and retry the lost execution<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A worker restart does not bring that execution back. Open the Executions tab on the Overview page, filter by workflow and by status Running or Failed, and find the run from the time of the crash. Confirm that <code>Worker started execution &lt;id&gt; (job &lt;id&gt;)<\/code> has no matching <code>Worker finished execution &lt;id&gt; (job &lt;id&gt;)<\/code> line in any worker&#8217;s log.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then choose Retry execution in its options menu, then Retry with currently saved workflow or Retry with original workflow. The n8n docs warn that a retry reprocesses every item of the failed node, including items handled before the failure, so check the target system first: an email sent before the crash can go out again. An execution that never left the queue cannot be retried; the source answers <code>Execution is queued to run (not yet started) so it cannot be retried<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">JavaScript heap out of memory on a worker<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong><a href=\"https:\/\/docs.n8n.io\/hosting\/scaling\/memory-errors\/\" target=\"_blank\" rel=\"noopener\">n8n page on memory errors<\/a><\/strong> lists <code>Allocation failed - JavaScript heap out of memory<\/code> in the server log and <code>Execution stopped at this node (n8n may have run out of memory while executing it)<\/code> in the editor. Large items are the usual cause on a worker. Our levers, in order:<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li>Shrink the peak: split big inputs into sub-workflows that return small results, as the docs suggest, or lower <span class=\"highlighted\">&#8211;concurrency<\/span> on the worker.<\/li><li>Raise the heap and the container limit together. <span class=\"highlighted\">NODE_OPTIONS=&#8211;max-old-space-size=SIZE<\/span> (in MiB) lifts V8&#8217;s ceiling, and the Node.js docs suggest 1536 on a 2 GiB machine. Apply the same ratio to the worker&#8217;s <span class=\"highlighted\">mem_limit<\/span>.<\/li><li>If the VPS has free RAM for a second Node.js heap (see the sizing section), add a worker rather than growing one. Two workers at concurrency 5 give the same 10 slots as one at 10; the gain is isolation, as a runaway job crashes only its own process.<\/li><\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>  n8n-worker-1:\n    # ...as in the file below, plus:\n    mem_limit: 2g\n    environment:\n      &lt;&lt;: *n8n-env\n      QUEUE_HEALTH_CHECK_ACTIVE: \"true\"\n      NODE_OPTIONS: --max-old-space-size=1536<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With external runners, Code node memory is used in the runner sidecar, not in the worker, so watch both containers when you measure.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How n8n queue mode splits the work<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In queue mode the main instance serves the editor, listens for triggers and webhooks, saves each new execution to Postgres and pushes only its ID into a Bull queue in Redis. A worker takes the ID, loads the workflow from Postgres, runs it, writes the result back and reports through Redis.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every worker needs the same Redis and the same database as main, since the queue message carries an ID and nothing else; the <strong><a href=\"https:\/\/docs.n8n.io\/hosting\/scaling\/queue-mode\/\" target=\"_blank\" rel=\"noopener\">n8n queue mode documentation<\/a><\/strong> lists both requirements. Every worker also needs main&#8217;s encryption key, because it decrypts credentials itself.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow.webp\" alt=\"n8n queue mode flow: main instance pushes execution IDs into the Redis jobs queue, workers pull them, read and write Postgres, and report back through Redis\" class=\"wp-image-2950\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-flow-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><figcaption>Main never runs production executions in queue mode; it only writes them to Postgres and queues their IDs<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Since 2.0, Code node scripts run in task runners. The <strong><a href=\"https:\/\/docs.n8n.io\/hosting\/configuration\/task-runners\/\" target=\"_blank\" rel=\"noopener\">task runner documentation<\/a><\/strong> recommends external mode for production, where each worker gets its own <span class=\"highlighted\">n8nio\/runners<\/span> sidecar at the same version tag.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">An n8n queue mode Docker Compose file for one VPS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This file runs Postgres 18, Redis 8, main, one worker and its runner on a single host. Only main publishes a port, on <span class=\"highlighted\">127.0.0.1<\/span>, for a reverse proxy such as Caddy or nginx that terminates TLS. Postgres and Redis have no <span class=\"highlighted\">ports:<\/span> entry and stay on the Compose network. Published on the public interface, both could be reached from anywhere on the internet and their passwords brute-forced.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The file passes <span class=\"highlighted\">docker compose config<\/span>. Its variable names and defaults, like the log strings quoted above, were checked against docs.n8n.io and the n8n 2.46.6 source on November 5, 2026; the stack itself was not started.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Existing instance:<\/strong> run check 4 first and put its key into <span class=\"highlighted\">.env<\/span> as <span class=\"highlighted\">N8N_ENCRYPTION_KEY<\/span> before the first start; generate only the other three secrets.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fresh install:<\/strong> generate four secrets and paste one into each placeholder of <span class=\"highlighted\">.env<\/span>, along with your real host name in place of n8n.example.com:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>for i in 1 2 3 4; do openssl rand -hex 32; done<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># .env  (chmod 600; keep a copy off the server)\nN8N_VERSION=2.46.6\nN8N_HOST=n8n.example.com\nPOSTGRES_PASSWORD=first-openssl-value\nREDIS_PASSWORD=second-openssl-value\nN8N_ENCRYPTION_KEY=third-openssl-value-or-your-existing-key\nN8N_RUNNERS_AUTH_TOKEN=fourth-openssl-value<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># compose.yaml\nx-n8n-env: &amp;n8n-env\n  DB_TYPE: postgresdb\n  DB_POSTGRESDB_HOST: postgres\n  DB_POSTGRESDB_DATABASE: n8n\n  DB_POSTGRESDB_USER: n8n\n  DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}\n  EXECUTIONS_MODE: queue\n  QUEUE_BULL_REDIS_HOST: redis\n  QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}\n  N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}\n  OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: \"true\"\n  N8N_RUNNERS_MODE: external\n  N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}\n  N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0\n  N8N_GRACEFUL_SHUTDOWN_TIMEOUT: \"30\"\n  GENERIC_TIMEZONE: UTC\n\nx-n8n: &amp;n8n\n  image: docker.n8n.io\/n8nio\/n8n:${N8N_VERSION}\n  restart: unless-stopped\n  stop_grace_period: 45s\n  volumes:\n    - n8n_data:\/home\/node\/.n8n\n\nservices:\n  postgres:\n    image: postgres:18\n    restart: unless-stopped\n    environment:\n      POSTGRES_USER: n8n\n      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}\n      POSTGRES_DB: n8n\n      PGDATA: \/var\/lib\/postgresql\/data\n    volumes:\n      - pg_data:\/var\/lib\/postgresql\/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U n8n -d n8n\"]\n      interval: 10s\n      timeout: 5s\n      retries: 10\n\n  redis:\n    image: redis:8-alpine\n    restart: unless-stopped\n    command: [\"redis-server\", \"--requirepass\", \"${REDIS_PASSWORD}\", \"--appendonly\", \"yes\"]\n    environment:\n      REDISCLI_AUTH: ${REDIS_PASSWORD}\n    volumes:\n      - redis_data:\/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"redis-cli ping | grep -q PONG\"]\n      interval: 10s\n      timeout: 5s\n      retries: 10\n\n  n8n:\n    &lt;&lt;: *n8n\n    environment:\n      &lt;&lt;: *n8n-env\n      N8N_HOST: ${N8N_HOST}\n      N8N_PROTOCOL: https\n      N8N_WEBHOOK_URL: https:\/\/${N8N_HOST}\/\n      N8N_PROXY_HOPS: \"1\"\n    ports:\n      - \"127.0.0.1:5678:5678\"\n    depends_on:\n      postgres:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"fetch('http:\/\/127.0.0.1:5678\/healthz\/readiness').then(r=&gt;process.exit(r.ok?0:1)).catch(()=&gt;process.exit(1))\"]\n      interval: 15s\n      timeout: 5s\n      retries: 10\n      start_period: 60s\n\n  n8n-worker-1:\n    &lt;&lt;: *n8n\n    command: worker --concurrency=10\n    environment:\n      &lt;&lt;: *n8n-env\n      QUEUE_HEALTH_CHECK_ACTIVE: \"true\"\n    depends_on:\n      n8n:\n        condition: service_healthy\n    healthcheck:\n      test: [\"CMD\", \"node\", \"-e\", \"fetch('http:\/\/127.0.0.1:5678\/healthz\/readiness').then(r=&gt;process.exit(r.ok?0:1)).catch(()=&gt;process.exit(1))\"]\n      interval: 15s\n      timeout: 5s\n      retries: 10\n      start_period: 60s\n\n  n8n-worker-1-runner:\n    image: n8nio\/runners:${N8N_VERSION}\n    restart: unless-stopped\n    environment:\n      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}\n      N8N_RUNNERS_TASK_BROKER_URI: http:\/\/n8n-worker-1:5679\n    depends_on:\n      - n8n-worker-1\n\nvolumes:\n  pg_data:\n  redis_data:\n  n8n_data:<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose config --quiet &amp;&amp; docker compose up -d\ndocker compose ps\ndocker compose logs -f n8n-worker-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One <span class=\"highlighted\">N8N_VERSION<\/span> feeds both the n8n and runner images, which must match. <span class=\"highlighted\">OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true<\/span> sends editor runs to workers now, as 3.0 will anyway, so main needs no runner sidecar. The worker waits for main&#8217;s readiness check, so migrations finish before it connects.<\/p>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">I would send editor runs to workers from day one with OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true. n8n 3.0 makes it the only option anyway, and a test run then takes the same path as production.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<p class=\"wp-block-paragraph\"><span class=\"highlighted\">N8N_WEBHOOK_URL<\/span> exists from 2.30.0; older releases use <span class=\"highlighted\">WEBHOOK_URL<\/span>, an alias deprecated since 2.35.0.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a second worker, copy the <span class=\"highlighted\">n8n-worker-1<\/span> and <span class=\"highlighted\">n8n-worker-1-runner<\/span> blocks as <span class=\"highlighted\">n8n-worker-2<\/span> and <span class=\"highlighted\">n8n-worker-2-runner<\/span>, pointing the new runner at <span class=\"highlighted\">http:\/\/n8n-worker-2:5679<\/span>; <span class=\"highlighted\">&#8211;scale<\/span> does not fit, since each runner must reach one specific worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On the host firewall, allow only SSH, 80 and 443, as in our <strong><a href=\"https:\/\/hostry.com\/blog\/secure-new-linux-vps-first-30-minutes\/\">first-day hardening steps for an Ubuntu server<\/a><\/strong>, with the Hostry control panel firewall as a second layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To run n8n from a git-push panel instead, the <strong><a href=\"https:\/\/hostry.com\/blog\/coolify-vs-dokploy-vs-caprover\/\">Coolify, Dokploy and CapRover comparison<\/a><\/strong> covers what each panel needs from the server.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How many workers, and at what concurrency<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">We would start with one worker at concurrency 10 beside main and measure for a week before adding anything. Parallel production executions equal workers times concurrency; the docs recommend 5 or higher per worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>CPU<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each worker is one Node.js process whose JavaScript runs on one thread, so a second worker on the same server helps when the first sits near 100% (one full core) in <span class=\"highlighted\">CPUPerc<\/span> while other cores idle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Memory<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">During the busiest hour of a normal week, run this a few times and note the highest MEM USAGE for each worker and runner:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker stats --no-stream --format 'table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">We would add about a quarter on top of each peak for that container&#8217;s <span class=\"highlighted\">mem_limit<\/span>, a rule of thumb rather than a measured figure. When memory runs short, scale the VPS up rather than adding workers, since each extra worker brings its own Node.js heap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pick the server from that budget.<\/strong> Add those limits to what main, Postgres, Redis and the operating system use, and choose a <strong><a href=\"https:\/\/hostry.com\/services\/vps\/\">Hostry KVM VPS plan<\/a><\/strong> with that much RAM. Pricing begins at $6 per month as of November 2026, billed monthly, and resources can be raised later when a second worker needs room.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Database connections<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each n8n process, main or worker, keeps its own pool of up to <span class=\"highlighted\">DB_POSTGRESDB_POOL_SIZE<\/span> connections, 2 by default, while PostgreSQL&#8217;s <span class=\"highlighted\">max_connections<\/span> is typically 100, three of them reserved for superusers. With the default pool, two layouts with the same capacity compare like this:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Workers<\/th><th>Concurrency<\/th><th>Parallel executions<\/th><th>n8n processes (with main)<\/th><th>Postgres connections<\/th><\/tr><\/thead><tbody><tr><td>2<\/td><td>10<\/td><td>20<\/td><td>3<\/td><td>6<\/td><\/tr><tr><td>20<\/td><td>1<\/td><td>20<\/td><td>21<\/td><td>42<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The second row is the pool exhaustion the docs warn about, plus twenty Node.js heaps. Raise the pool size only if executions wait for connections under load, and redo the sum: <span class=\"highlighted\">DB_POSTGRESDB_POOL_SIZE=4<\/span> on two workers and main is 3 \u00d7 4 = 12 connections.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing.webp\" alt=\"n8n worker sizing: workers times concurrency gives parallel executions, processes times DB_POSTGRESDB_POOL_SIZE must stay well under PostgreSQL max_connections\" class=\"wp-image-2951\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/n8n-queue-mode-workers-not-picking-up-jobs-sizing-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><figcaption>The same 20 execution slots cost 6 Postgres connections as 2 workers at concurrency 10, and 42 as 20 workers at concurrency 1<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Optional: workers on a second server<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Workers on another VPS reach Redis and Postgres over WireGuard or an SSH tunnel, with both bound to the tunnel address and the firewall open only to the peer (say 203.0.113.10).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Updating a queue-mode stack and what n8n 3.0 changes for workers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Main, workers and runners should always run one release. n8n patches often: the <strong><a href=\"https:\/\/github.com\/n8n-io\/n8n\/security\/advisories\" target=\"_blank\" rel=\"noopener\">n8n security advisories on GitHub<\/a><\/strong> list ten published on October 14, 2026 alone, three of them rated High. Keep the version pinned in <span class=\"highlighted\">N8N_VERSION<\/span> in <span class=\"highlighted\">.env<\/span> and change it only when you choose to update:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose exec -T postgres pg_dump -U n8n -Fc n8n &gt; n8n-$(date +%F).dump\n# edit N8N_VERSION in .env, then:\ndocker compose pull\ndocker compose stop n8n-worker-1-runner n8n-worker-1\ndocker compose up -d<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The worker finishes its jobs within the grace period; main migrates on start, and the worker rejoins once main is ready.<\/p>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">Pin the version in .env and update main, workers and runners together in one maintenance window. A queue-mode stack running mixed versions is not a state worth debugging.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<p class=\"wp-block-paragraph\">n8n 3.0.0 shipped as a beta on October 27, 2026; the stable release is planned for early December, after which 2.x receives security and critical bug fixes for 12 months. The <strong><a href=\"https:\/\/docs.n8n.io\/changelog\/v30-breaking-changes\" target=\"_blank\" rel=\"noopener\">n8n 3.0 breaking changes<\/a><\/strong> that touch a queue-mode stack:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><span class=\"highlighted\">OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS<\/span> is removed and manual runs always go to workers, so review worker memory.<\/li><li>Self-hosting is Docker only; npm and <span class=\"highlighted\">npx n8n<\/span> installs are no longer supported.<\/li><li>On first start, <span class=\"highlighted\">~\/.n8n\/binaryData<\/span> is renamed to <span class=\"highlighted\">~\/.n8n\/storage<\/span>; if both exist, n8n stops with an error, and <span class=\"highlighted\">N8N_STORAGE_PATH<\/span> keeps the old path.<\/li><li>The <span class=\"highlighted\">N8N_RUNNERS_TASK_TIMEOUT<\/span> default drops from 300 to 60 seconds.<\/li><li>Encryption key rotation is on by default; older data stays readable and is re-encrypted on its next update.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Internal runner mode is deprecated in 3.0, which the file above already avoids. Before upgrading, open <span class=\"highlighted\">Settings &gt; Migration Report<\/span> on the 2.x instance, then try 3.0 on a copy restored from the dump.<\/p>\n<!-- AddThis Advanced Settings generic via filter on the_content --><!-- AddThis Share Buttons generic via filter on the_content -->","protected":false},"excerpt":{"rendered":"<p>When n8n queue mode workers sit idle while executions pile up in Queued, the cause is usually a mismatch between main and worker. Six checks in order &#8230;<\/p>\n","protected":false},"author":8,"featured_media":2948,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,78],"tags":[],"class_list":["post-2953","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-virtual-private-server","category-apps"],"read_time":11,"_links":{"self":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/2953","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/users\/8"}],"replies":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/comments?post=2953"}],"version-history":[{"count":1,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/2953\/revisions"}],"predecessor-version":[{"id":2954,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/2953\/revisions\/2954"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media\/2948"}],"wp:attachment":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media?parent=2953"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/categories?post=2953"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/tags?post=2953"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}