Tutorial

n8n Task Runners: External Mode, Native Python, and Safe Code Execution

Shannon AtkinsonSeptember 6, 202616 min read
n8n Task Runners: External Mode, Native Python, and Safe Code Execution

This post is adapted from Chapter 4 of the free book The Ultimate Guide to Deploying n8n Community Edition (2nd edition, September 2026, covering n8n 2.38). Grab the full PDF or EPUB, download the free self-hosting starter excerpt, or join the House of Loops community to swap workflows with other self-hosters.

Overview

The Code node runs code that a workflow author typed into a browser. Since n8n 2.0, every Code node execution runs on a task runner. Task runners exist so that this code runs somewhere other than inside the n8n process.

The n8n docs are blunt about the stakes: task runners are the only isolation layer between user-provided code and n8n. Without them, or with a runner in internal mode, anyone who can edit a workflow could potentially read your database, your encryption key, your stored credentials, and your environment variables. That is not a theoretical concern on a shared instance. It is the reason this guide exists.

In this guide you will:

  1. Learn what a task runner is and how the runner, broker, and requester fit together
  2. Compare internal mode and external mode, and see exactly what each one exposes
  3. Run the n8nio/runners sidecar next to n8n with a complete compose stack
  4. Enable native Python in the Code node
  5. Wire runners correctly in queue mode, where each worker needs its own sidecar
  6. Add npm and pip packages by extending the runners image and allowlisting modules
  7. Understand what 2.0 broke inside the Code node and why
  8. Set resource limits, health checks, and the Kubernetes equivalent
A Code node task requester sends a task request to the n8n broker on port 5679, which hands it to a task runner over a websocket, which returns a result

Prerequisites

  • A working n8n stack from the reference compose.yml, running n8nio/n8n:2.38.1
  • Docker Engine with Compose v2 (docker compose)
  • A long random string for N8N_RUNNERS_AUTH_TOKEN, generated once and stored in .env
  • Shell access to the host so you can read container logs

1. What a Task Runner Is and Why It Exists

A task runner is a separate process whose only job is to execute one piece of user code and hand back the result. The Code node does not run JavaScript or Python itself any more. It packages the code and the input items into a task, sends the task away, and waits for an answer.

The point is blast radius. n8n holds your credential store and the N8N_ENCRYPTION_KEY that decrypts it. If arbitrary code runs inside that process, a sandbox escape reaches everything the process can reach. If the code runs in a different container, with a different filesystem, no database connection, and no credentials in its environment, an escape reaches a container that has nothing worth stealing.

Since 2.0 you no longer opt in. N8N_RUNNERS_ENABLED is deprecated and you can drop it. What you still choose is the mode, and that choice is the whole security story.

2. The Three Components

The feature has three parts, and it helps to name them because the log lines do.

Task requester. The Code node, running inside n8n. It submits a task and waits for the result.

Task broker. The n8n instance itself — main or worker — acts as the broker. It listens on port 5679 (N8N_RUNNERS_BROKER_PORT) and coordinates between requesters and runners. It does not execute anything.

Task runner. The process that actually executes the code. In external mode this lives in the sidecar container, started and supervised by a launcher application.

Runners connect outward to the broker over a websocket and authenticate with a shared secret. The broker then hands them tasks over that same connection. The runner sends a heartbeat every N8N_RUNNERS_HEARTBEAT_INTERVAL seconds (default 30); if it stops, the broker kills the task and the runner restarts.

The direction of that connection matters for your network design. The runner dials the broker, so the broker's port must be reachable from the runner, and the runner needs no inbound path from n8n at all.

3. Internal Mode Versus External Mode

N8N_RUNNERS_MODE takes two values, and the default is the wrong one for production.

internal (the default). n8n launches the runner as a child process of itself. Same host, same uid and gid, same filesystem, same environment. The n8n process manages its lifecycle. This is insecure by design and the docs say so. Code that escapes the runner's sandbox lands with exactly the access n8n has: the credentials table, the encryption key, the Postgres connection, the mounted volumes. Internal mode is acceptable on a throwaway instance holding mock data and nothing else.

external. A launcher application in a separate container starts runners on demand and supervises them. The runner container has its own filesystem and its own environment. It never receives N8N_ENCRYPTION_KEY, never receives DB_POSTGRESDB_PASSWORD, and cannot open a socket to Postgres unless you put it on that network yourself. An escape gets an attacker a shell in a container whose contents are a Node.js runtime, a Python runtime, and the code they already wrote.

External mode is what the rest of this guide configures. One practical note: the runners image does not support file-based configuration. Variables with a _FILE suffix are ignored there, so pass the auth token as a plain environment variable from your .env.

4. The n8nio/runners Sidecar

The sidecar image contains the launcher, the JavaScript runner, and the Python runner. Its version must match the n8n image version exactly. These two images speak a private protocol; a mismatch produces connection errors or tasks that never complete.

.env:

# Generate with: openssl rand -hex 32
N8N_ENCRYPTION_KEY=replace-with-64-hex-characters
N8N_RUNNERS_AUTH_TOKEN=replace-with-a-long-random-string

POSTGRES_USER=n8n
POSTGRES_PASSWORD=replace-me
POSTGRES_DB=n8n

N8N_HOST=n8n.example.com
GENERIC_TIMEZONE=America/Los_Angeles

compose.yml:

services:
  postgres:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: n8nio/n8n:2.38.1
    restart: unless-stopped
    ports:
      - '127.0.0.1:5678:5678'
    environment:
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_HOST: ${N8N_HOST}
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://${N8N_HOST}/
      N8N_EDITOR_BASE_URL: https://${N8N_HOST}/
      N8N_PROXY_HOPS: 1
      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${GENERIC_TIMEZONE}
      N8N_RUNNERS_MODE: external
      N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
      N8N_NATIVE_PYTHON_RUNNER: 'true'
      N8N_DEFAULT_BINARY_DATA_MODE: filesystem
      EXECUTIONS_DATA_PRUNE: 'true'
      EXECUTIONS_DATA_MAX_AGE: 336
      N8N_DIAGNOSTICS_ENABLED: 'false'
    volumes:
      - n8n_data:/home/node/.n8n
      - n8n_files:/home/node/.n8n-files
    depends_on:
      postgres:
        condition: service_healthy

  runners:
    image: n8nio/runners:2.38.1
    restart: unless-stopped
    environment:
      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
      N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: 15
    depends_on:
      - n8n

volumes:
  postgres_data:
  n8n_data:
  n8n_files:

What each runner variable does:

  • N8N_RUNNERS_MODE=external tells n8n not to fork a child process and to wait for a runner to connect instead.
  • N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 is required. The broker binds 127.0.0.1 by default, which is unreachable from a second container. Without this, the runner will never connect.
  • N8N_RUNNERS_AUTH_TOKEN must be byte-identical on both services. It is the only thing standing between your broker port and anything else that can reach it.
  • N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679 uses the compose service name. If you rename the n8n service, change this too.
  • N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT=15 stops an idle runner process after 15 seconds. The launcher restarts it when the next task arrives. Set 0 to keep runners resident, which trades memory for a small latency saving on the first Code node after a quiet period.

Bring it up and confirm the connection:

docker compose up -d
docker compose logs -f runners

You are looking for the launcher reporting a successful connection to the broker. Then open any workflow, add a Code node with return [{json: {ok: true}}], and run it.

5. Native Python

A workflow with a JavaScript Code node and a native Python Code node, both executed on the external runner. The runner container logs show the launcher:js and launcher:py processes accepting the tasks.

n8n 2.0 removed the Pyodide-based Python Code node and replaced it with a native Python runner. Set N8N_NATIVE_PYTHON_RUNNER=true on the n8n container, as in the stack above. Python in the Code node works only with external mode.

Two differences will bite you when migrating old workflows:

  • The native runner does not provide the Pyodide built-ins. _input is gone, and so is dot-access notation on items. Read items through the documented native API instead.
  • In a Python Code tool — the Code node attached to an AI agent — the agent's query arrives as _query.

Everything else is a normal CPython process, which is why numpy and pandas are now realistic (see section 7) and why the performance is nothing like Pyodide's.

6. Runners in Queue Mode

Queue mode changes the rule, and this is the most common misconfiguration in production stacks. Our queue mode guide covers the full multi-worker stack; here is the runner-specific piece.

Every n8n process that acts as a broker needs its own runners sidecar. Each worker is a broker for the Code nodes it executes. One shared runners container pointed at main does not serve the workers.

So:

  • Each n8n-worker gets its own runners service pointing at that worker's port 5679.
  • Main also needs a sidecar unless OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true, which hands manual editor executions to workers so main never runs a Code node. Running with offloading disabled is not recommended for production anyway.

A worker plus its runner, as a fragment of the queue-mode stack:

n8n-worker:
  image: n8nio/n8n:2.38.1
  restart: unless-stopped
  command: worker --concurrency=10
  environment:
    N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
    DB_TYPE: postgresdb
    DB_POSTGRESDB_HOST: postgres
    DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
    DB_POSTGRESDB_USER: ${POSTGRES_USER}
    DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
    EXECUTIONS_MODE: queue
    QUEUE_BULL_REDIS_HOST: redis
    N8N_DEFAULT_BINARY_DATA_MODE: database
    N8N_RUNNERS_MODE: external
    N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
    N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
    N8N_NATIVE_PYTHON_RUNNER: 'true'
    GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
    TZ: ${GENERIC_TIMEZONE}
  depends_on:
    postgres:
      condition: service_healthy

worker-runners:
  image: n8nio/runners:2.38.1
  restart: unless-stopped
  environment:
    N8N_RUNNERS_TASK_BROKER_URI: http://n8n-worker:5679
    N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
    N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: 15
  depends_on:
    - n8n-worker

If you scale workers with docker compose up -d --scale, this pairing breaks — the service name resolves to several containers and runners will attach arbitrarily. Define workers as distinct services (n8n-worker-1, n8n-worker-2) each with its own sidecar, or move to an orchestrator that models sidecars properly.

7. Adding npm and pip Dependencies

The runners image ships a deliberately minimal set of modules, and the launcher configuration file is locked down. Adding a package takes two steps: install it in the image, then allowlist it for the Code node. Doing only the first gives you a package the Code node still refuses to import.

Extend the image:

FROM n8nio/runners:2.38.1
USER root
RUN cd /opt/runners/task-runner-javascript && pnpm add moment uuid
RUN cd /opt/runners/task-runner-python && uv pip install numpy pandas
COPY n8n-task-runners.json /etc/n8n-task-runners.json
USER runner

The launcher reads its configuration from /etc/n8n-task-runners.json. You can bake it in with COPY, as above, or mount it at that path from the host:

volumes:
  - ./n8n-task-runners.json:/etc/n8n-task-runners.json:ro

n8n-task-runners.json:

{
  "task-runners": [
    {
      "runner-type": "javascript",
      "env-overrides": {
        "NODE_FUNCTION_ALLOW_BUILTIN": "crypto",
        "NODE_FUNCTION_ALLOW_EXTERNAL": "moment,uuid"
      }
    },
    {
      "runner-type": "python",
      "env-overrides": {
        "PYTHONPATH": "/opt/runners/task-runner-python",
        "N8N_RUNNERS_STDLIB_ALLOW": "json",
        "N8N_RUNNERS_EXTERNAL_ALLOW": "numpy,pandas"
      }
    }
  ]
}

The four allowlists are:

  • NODE_FUNCTION_ALLOW_BUILTIN — Node.js built-in modules
  • NODE_FUNCTION_ALLOW_EXTERNAL — third-party JavaScript packages
  • N8N_RUNNERS_STDLIB_ALLOW — Python standard library modules
  • N8N_RUNNERS_EXTERNAL_ALLOW — third-party Python packages

Keep these lists short and specific. Every entry is a capability you are granting to anyone who can edit a workflow. Allowlisting child_process or fs hands back most of what external mode took away.

Build and pin your own tag, and rebuild it every time you bump n8n:

docker build -t registry.example.com/n8n-runners:2.38.1 .

8. What 2.0 Broke, and the Escape Hatch You Should Not Use

Two changes catch people upgrading. Our n8n 2.0 upgrade guide covers the full breaking-changes checklist; here are the two that touch the Code node directly.

$evaluateExpression() no longer works in the Code node. Code node executions now run on runners in secure mode, and secure mode disables evaluating strings as code — which is precisely the mechanism expressions rely on. Calls return null or throw. Expressions in ordinary node fields, such as Edit Fields (Set), are unaffected. Rewrite the logic in plain JavaScript.

N8N_RUNNERS_INSECURE_MODE=true disables all security measures in the runner for compatibility with modules that need those insecure features. The docs discourage it for production and so do we: turning it on undoes the isolation you set up in section 4 while leaving the sidecar in place, which is worse than obvious, because the stack still looks correct.

N8N_BLOCK_ENV_ACCESS_IN_NODE now defaults to true. Code nodes cannot read process.env. If a workflow depended on that, the migration path is to move the value into a credential. Setting N8N_BLOCK_ENV_ACCESS_IN_NODE=false restores the old behaviour and re-exposes every environment variable on the process to anyone who can edit a workflow.

9. Limits, Health Checks, and Kubernetes

The sidecar runs untrusted code, so cap what it can consume. A runaway loop should hit a limit, not the host's OOM killer.

N8N_RUNNERS_MAX_CONCURRENCY and N8N_RUNNERS_MAX_OLD_SPACE_SIZE are both n8n instance environment variables per the docs, not runner variables — add them to the n8n service's environment block:

N8N_RUNNERS_MAX_CONCURRENCY: 5
N8N_RUNNERS_MAX_OLD_SPACE_SIZE: 1024

The runners sidecar itself only needs its connection settings, a resource ceiling, and a health check:

runners:
  image: n8nio/runners:2.38.1
  restart: unless-stopped
  environment:
    N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
    N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
    N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: 15
  deploy:
    resources:
      limits:
        cpus: '2.0'
        memory: 2G
  healthcheck:
    test: ['CMD-SHELL', 'wget -q -O- http://127.0.0.1:5680/healthz || exit 1']
    interval: 30s
    timeout: 5s
    retries: 3
  depends_on:
    - n8n

The launcher serves /healthz on port 5680 (N8N_RUNNERS_LAUNCHER_HEALTH_CHECK_PORT); the broker serves /healthz on 5679. If your image has no wget, drop the healthcheck block and probe port 5680 from outside instead. To be clear about where each setting lives: N8N_RUNNERS_MAX_CONCURRENCY (default 5) and N8N_RUNNERS_TASK_TIMEOUT (default 300 seconds) are set on the n8n container, not the runner; so is N8N_RUNNERS_MAX_OLD_SPACE_SIZE, which caps the Node.js heap (in MB) that the runner is launched with.

On Kubernetes, put the launcher in the same pod as the n8n main or worker container. They share the pod network, so N8N_RUNNERS_TASK_BROKER_URI=http://127.0.0.1:5679 and you can leave the broker on its default listen address. Give the sidecar its own resources.limits and a livenessProbe on 5680. If you prefer a separate Deployment, keep N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0, expose 5679 through a Service, and point the runners at it — but you lose the one-runner-per-broker guarantee, so the sidecar pattern is the safer default.

10. Troubleshooting Common Issues

IssueCauseSolution
Runner never connects; broker logs show nothingN8N_RUNNERS_AUTH_TOKEN differs between the two servicesCompare with docker compose exec n8n printenv N8N_RUNNERS_AUTH_TOKEN and the same on runners. A trailing space or unexported .env value is the usual cause
Runner logs "connection refused" to port 5679Broker still bound to 127.0.0.1Set N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 on the n8n container and recreate it
Runner connects, then drops repeatedlyVersion mismatch between n8nio/n8n and n8nio/runnersPin both to the same tag (2.38.1) and recreate both containers
"Code node timed out"Task exceeded N8N_RUNNERS_TASK_TIMEOUT (300s), or no runner was free within N8N_RUNNERS_TASK_REQUEST_TIMEOUT (60s)Raise the timeout, raise N8N_RUNNERS_MAX_CONCURRENCY, or add a second sidecar. Also check N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT isn't fighting a very slow first task
Code node hangs forever in queue modeA worker has no runners sidecarGive every worker its own sidecar pointing at that worker's :5679
Manual runs hang, scheduled runs workMain has no sidecar and manual executions aren't offloadedSet OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true, or add a sidecar for main
Python: ModuleNotFoundError for an installed packagePackage installed but not allowlistedAdd it to N8N_RUNNERS_EXTERNAL_ALLOW (or N8N_RUNNERS_STDLIB_ALLOW) in /etc/n8n-task-runners.json and restart the sidecar
JS: "Cannot find module"Same, on the JavaScript sideAdd it to NODE_FUNCTION_ALLOW_EXTERNAL, and confirm pnpm add ran in /opt/runners/task-runner-javascript
Python Code node unavailable in the editorN8N_NATIVE_PYTHON_RUNNER unset, or mode is internalSet N8N_NATIVE_PYTHON_RUNNER=true and N8N_RUNNERS_MODE=external on n8n
_input or dot access throws in PythonPyodide built-ins removed in 2.0Rewrite against the native Python API; in Code tools use _query
$evaluateExpression() returns nullSecure mode disables string evaluationRewrite as plain JavaScript. Do not enable N8N_RUNNERS_INSECURE_MODE
process.env is empty in a Code nodeN8N_BLOCK_ENV_ACCESS_IN_NODE defaults to true since 2.0Move the value into a credential rather than flipping the flag
Token set via N8N_RUNNERS_AUTH_TOKEN_FILE is ignoredThe runners image has no file-based configurationPass the plain variable from .env

Further reading


Got a Code node that will not cooperate with the runner? Post it in the House of Loops community and get eyes on it fast, or join the free weekly workflow list and get one delivered to your inbox every week.

S

Shannon Atkinson

House of Loops is a technology-focused community for learning and implementing advanced automation workflows using n8n, Strapi, AI/LLM, and DevSecOps tools.

Join Our Community