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:
- Learn what a task runner is and how the runner, broker, and requester fit together
- Compare internal mode and external mode, and see exactly what each one exposes
- Run the
n8nio/runnerssidecar next to n8n with a complete compose stack - Enable native Python in the Code node
- Wire runners correctly in queue mode, where each worker needs its own sidecar
- Add npm and pip packages by extending the runners image and allowlisting modules
- Understand what 2.0 broke inside the Code node and why
- Set resource limits, health checks, and the Kubernetes equivalent
Prerequisites
- A working n8n stack from the reference
compose.yml, runningn8nio/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=externaltells n8n not to fork a child process and to wait for a runner to connect instead.N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0is required. The broker binds127.0.0.1by default, which is unreachable from a second container. Without this, the runner will never connect.N8N_RUNNERS_AUTH_TOKENmust 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:5679uses the compose service name. If you rename then8nservice, change this too.N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT=15stops an idle runner process after 15 seconds. The launcher restarts it when the next task arrives. Set0to 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

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.
_inputis 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-workergets its ownrunnersservice 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 modulesNODE_FUNCTION_ALLOW_EXTERNAL— third-party JavaScript packagesN8N_RUNNERS_STDLIB_ALLOW— Python standard library modulesN8N_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
| Issue | Cause | Solution |
|---|---|---|
| Runner never connects; broker logs show nothing | N8N_RUNNERS_AUTH_TOKEN differs between the two services | Compare 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 5679 | Broker still bound to 127.0.0.1 | Set N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 on the n8n container and recreate it |
| Runner connects, then drops repeatedly | Version mismatch between n8nio/n8n and n8nio/runners | Pin 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 mode | A worker has no runners sidecar | Give every worker its own sidecar pointing at that worker's :5679 |
| Manual runs hang, scheduled runs work | Main has no sidecar and manual executions aren't offloaded | Set OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true, or add a sidecar for main |
Python: ModuleNotFoundError for an installed package | Package installed but not allowlisted | Add 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 side | Add it to NODE_FUNCTION_ALLOW_EXTERNAL, and confirm pnpm add ran in /opt/runners/task-runner-javascript |
| Python Code node unavailable in the editor | N8N_NATIVE_PYTHON_RUNNER unset, or mode is internal | Set N8N_NATIVE_PYTHON_RUNNER=true and N8N_RUNNERS_MODE=external on n8n |
_input or dot access throws in Python | Pyodide built-ins removed in 2.0 | Rewrite against the native Python API; in Code tools use _query |
$evaluateExpression() returns null | Secure mode disables string evaluation | Rewrite as plain JavaScript. Do not enable N8N_RUNNERS_INSECURE_MODE |
process.env is empty in a Code node | N8N_BLOCK_ENV_ACCESS_IN_NODE defaults to true since 2.0 | Move the value into a credential rather than flipping the flag |
Token set via N8N_RUNNERS_AUTH_TOKEN_FILE is ignored | The runners image has no file-based configuration | Pass the plain variable from .env |
Further reading
- https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners
- https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners
- https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/harden-task-runners
- https://docs.n8n.io/changelog/v20-breaking-changes
- https://docs.n8n.io/deploy/host-n8n/configure-n8n/scaling/enable-queue-mode
- https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/queue-mode
- https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code
- https://github.com/n8n-io/task-runner-launcher/blob/main/docs/setup.md
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.
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
![Getting Started with n8n: Complete Self-Hosting Guide [2025]](/blog/images/getting-started-n8n.jpg)