How to Run n8n Code Nodes with External Task Runners in Docker
Introduction
If you self-host n8n on Docker and use the Code node, the way those scripts run changed underneath you. Internal mode, where n8n launches the task runner as a child process, is deprecated since n8n 3.0 and will be removed in a future version. The supported production path is external mode: a sidecar container running the n8nio/runners image next to n8n. This tutorial walks you through the full setup on Docker Compose, including the shared auth token wiring and the Python Code node configuration that only the external runner image provides.
Quick answer
Set N8N_RUNNERS_MODE=external on the n8n container, add an n8nio/runners sidecar with the same auth token and N8N_RUNNERS_TASK_BROKER_URI pointing at the n8n broker, and pin both images to the same version tag. That is the entire migration.
Evidence
Four facts from n8n’s own documentation explain why this setup is mandatory, not optional. First, the security boundary: n8n’s task runner setup guide states that task runners are the only isolation layer between user-provided code and n8n, and warns that without external mode, anyone who can edit a workflow could potentially read the database, the encryption key, stored credentials, and environment variables. Internal mode shares the same user ID and group ID as the n8n process, so sandbox-escaping code inherits n8n’s full access. That is the official reason external mode exists.
Second, the deprecation is already in effect. The same guide notes that internal mode is deprecated from n8n 3.0, will be removed in a future version, and that n8n logs a deprecation warning at startup while it runs in internal mode, including when N8N_RUNNERS_MODE is not set at all. If you see that warning in your logs after upgrading, it is not a bug; it is the migration notice.
Third, the timeout cut that made task runners famous also bites here. As of October 2026, the task runner task timeout default dropped from 300 seconds to 60 seconds in n8n 3.0, an 80% reduction in how long a Code node task may run. The change landed in n8n-io/n8n PR #37042 and is covered in depth in our guide to fixing the 3.0 Code node timeout. You need the timeout variable under your control before you restructure runners, not after.
Fourth, the version floor. External mode requires n8n 1.111.0 or newer, and the n8nio/runners image version must match the n8nio/n8n image version. The runners image ships the launcher plus the JavaScript and Python task runners as one unit, so a version skew can leave the broker and runners speaking different protocol versions. Pin both tags.
“Task runners are the only isolation layer between user-provided code and n8n. Without them, or with internal mode, anyone who can edit a workflow could potentially read your database, encryption key, stored credentials, and environment variables.”
That quote, from the official task runner docs, is the one sentence that settles the internal-vs-external debate for any instance holding real data.
Prerequisites
- Self-hosted n8n on Docker or Docker Compose, version 1.111.0 or newer (3.x recommended)
- Shell access to the host, with permission to edit
docker-compose.ymland restart containers - Admin access to the n8n UI for testing Code nodes after the switch
- OpenSSL on the host to generate the shared auth token
Internal vs external at a glance
Before touching configuration, it helps to see exactly what changes:
| Aspect | Internal mode | External mode |
|---|---|---|
| Runner process | Child process of n8n | Independent sidecar container (n8nio/runners) |
| OS user | Same UID/GID as n8n | Isolated container |
| Status in 3.0 | Deprecated, removal planned | Supported production path |
| Python runner | Limited legacy path | Full native runner with allowlists |
| Scaling | One runner per instance | Extra sidecars per worker |
| Startup behavior in 3.0 | Logs a deprecation warning | Silent, healthy startup |
The Code node docs add one more decisive detail for Python users: native Python support via task runners is available from n8n 1.111.0 and stable as of n8n 2, while the old Pyodide-based Python was removed in n8n 2.0. On self-hosted installs, native Python can import standard library and third-party modules only if the runners image includes them and explicitly allowlists them. That allowlist machinery lives in the external runners image, which is why a serious Python Code node setup means external mode. Read the Code node reference for the full Python syntax differences before you migrate scripts.
Step-by-step guide
Step 1. Generate the shared auth token
The n8n broker and the runners sidecar authenticate with a shared secret, N8N_RUNNERS_AUTH_TOKEN. Generate a strong one and store it in a .env file next to your compose file, so both services read the same value:
cd /path/to/your/n8n-compose
echo "N8N_RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)" > .env
chmod 600 .env
Do not paste the token into chat logs or commit it to version control. If the tokens on the two containers ever differ, runners fail to register with the broker, and Code nodes fail with task request timeouts.
Step 2. Add the runners sidecar to docker-compose.yml
Here is the minimal external-mode setup, adapted from the official reference compose. Both images share the version tag, the n8n broker listens on all interfaces inside the compose network, and the runners sidecar points at it:
services:
n8n:
image: n8nio/n8n:3.0.0
container_name: n8n-main
environment:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_NATIVE_PYTHON_RUNNER=true
- GENERIC_TIMEZONE=Asia/Singapore
env_file:
- .env
ports:
- "5678:5678"
volumes:
- n8n_data:/home/node/.n8n
task-runners:
image: n8nio/runners:3.0.0
container_name: n8n-runners
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n-main:5679
- N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
env_file:
- .env
depends_on:
- n8n
volumes:
n8n_data:
Three details matter. N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 is required because the broker listens on localhost by default, and the sidecar is a separate container that cannot reach localhost inside n8n’s network namespace. N8N_RUNNERS_TASK_BROKER_URI uses the compose service name n8n-main on port 5679, the broker’s port. And N8N_NATIVE_PYTHON_RUNNER=true enables the native Python runner path that the Code node’s Python mode expects.
If you followed our Docker Compose setup guide earlier, this file slots into the same directory; keep your existing database, encryption key, and volume settings and only add the runner wiring.
Step 3. Start and verify the broker handshake
docker compose up -d
docker compose ps
docker compose logs n8n-main --tail 40 | grep -i -E "deprecat|runner" || echo "no runner warnings"
You want two healthy containers and, critically, no internal-mode deprecation warning in the n8n logs. Then check the runners sidecar picked up tasks by running one manually.
Step 4. Test with a JavaScript Code node
Create a test workflow with a Manual Trigger and a Code node set to JavaScript:
const items = $input.all().map((item, i) => ({
json: { index: i, doubled: (item.json.value ?? i) * 2 },
}));
return items;
Run it manually. A successful execution proves the JS runner registered with the broker. If it fails instantly, the failure is in the broker wiring, not your script; see the troubleshooting section.
Step 5. Test the Python Code node
Switch the same Code node to Python mode and run:
for item in _items:
value = item["json"].get("value", 0)
item["json"]["doubled"] = value * 2
return _items
Note the bracket access notation. Unlike the removed Pyodide engine, native Python only accepts item["json"]["value"], not dot access like item.json.value. That syntax difference is documented in the Code node reference and trips up everyone migrating old Python snippets. Also keep in mind that native Python supports only _items in all-items mode and _item in per-item mode; other n8n built-in helpers are JavaScript-only.
Step 6. Configure Python package allowlists
By default, the runners image is locked down: most standard library and all third-party packages are denied until allowlisted. Open the n8n Code node and try import numpy; it fails until you extend the image. The official workflow is to build a custom runners image and mount a launcher config. Create n8n-task-runners.json:
{
"task-runners": [
{
"runner-type": "javascript",
"env-overrides": {
"NODE_FUNCTION_ALLOW_BUILTIN": "crypto",
"NODE_FUNCTION_ALLOW_EXTERNAL": "moment"
}
},
{
"runner-type": "python",
"env-overrides": {
"N8N_RUNNERS_STDLIB_ALLOW": "json,datetime,re",
"N8N_RUNNERS_EXTERNAL_ALLOW": "numpy,pandas"
}
}
]
}
Build the extended image from the official Dockerfile pattern:
FROM n8nio/runners:3.0.0
USER root
RUN cd /opt/runners/task-runner-javascript && pnpm add moment
RUN cd /opt/runners/task-runner-python && uv pip install numpy pandas
COPY n8n-task-runners.json /etc/n8n-task-runners.json
USER runner
Then point the compose file’s task-runners service at your custom image tag and restart. The launcher inside the image reads /etc/task-runners.json and applies the allowlists to the right runner type. Keep the allowlist minimal: every module you allow expands what a workflow editor’s code can reach.
Step 7. Harden the production setup
Two final moves. First, confirm the whole fleet after any n8n upgrade: the version tags must stay matched, so upgrade both images in the same maintenance window and check docker compose ps shows the new tags. Second, if you run queue mode, remember each worker needs its own sidecar, and the main instance needs one too unless you offload manual executions to workers. A single shared sidecar across workers is a silent misconfiguration that surfaces as intermittent task request timeouts under load.
Troubleshooting
1. “Task request timed out — Your Code node task was not matched to a runner within the timeout period.”
This means the broker never got a runner. Check in order: N8N_RUNNERS_BROKER_LISTEN_ADDRESS is 0.0.0.0 on the n8n container (the default localhost is unreachable from the sidecar), the auth tokens on both containers are identical (docker compose exec n8n-main env | grep AUTH vs the runners container), and N8N_RUNNERS_TASK_BROKER_URI uses the correct compose service name and port 5679. A token mismatch is the most common cause after copy-pasting between files.
2. Python Code node fails on import of a standard package
Native Python denies insecure built-ins and non-allowlisted packages by default. import os style calls and third-party imports fail until you add them to N8N_RUNNERS_STDLIB_ALLOW or N8N_RUNNERS_EXTERNAL_ALLOW in the launcher config and rebuild the image as in Step 6. Also check you are not relying on Pyodide behavior: Pyodide was removed in n8n 2.0, so old snippets using dot access or Pyodide-only packages need rewriting for the native runner.
3. The internal-mode deprecation warning appears even after switching to external
This happens when N8N_RUNNERS_MODE=external landed on the wrong service or the container was not recreated. Run docker compose exec n8n-main env | grep N8N_RUNNERS_MODE; if it prints nothing, the variable never reached the n8n container. Also remove any leftover N8N_RUNNERS_ENABLED=true, which is deprecated since 2.0 and belongs to the 1.x era. Recreate with docker compose up -d --force-recreate n8n.
4. The runners container exits immediately on start
Read its logs with docker compose logs task-runners. The usual causes are an unreachable broker URI (wrong service name, or the n8n container not finished starting) and a protocol mismatch from skewed image tags, for example n8nio/n8n:3.0.0 with n8nio/runners:2.1.0. Match the tags, keep depends_on, and restart.
5. Long scripts die after about 60 seconds in 3.0
That is the 3.0 task timeout default, not a runner connectivity problem. External mode does not restore the old five-minute limit. Set N8N_RUNNERS_TASK_TIMEOUT on both the n8n container and the runners sidecar, or restructure the workflow. The full diagnosis is in our Code node timeout fix guide.
FAQ
The FAQ block in the front matter above covers the questions readers ask most: whether runners matter without Code nodes, mixing modes, the 1.111.0 version floor, queue-mode sidecars per worker, the unexplained deprecation warning, and the 60-second timeout interaction.
Conclusion
External task runners are one compose service, one shared token, and one broker address away, and they are the only Code node mode n8n still supports going forward. Set it up now while internal mode still runs, test both the JavaScript and Python paths, and keep the image tags matched through every upgrade. Next, read our n8n 3.0 upgrade checklist to clear the other breaking changes in the same maintenance window.
Frequently asked questions
Do I need external task runners if my workflows never use the Code node?
No. Task runners only execute Code node scripts, both JavaScript and Python. If your workflows use only built-in nodes, external mode changes nothing, and you can ignore it. The mode only matters once a Code node enters the picture, at which point external mode is the supported way to run it.
Can I run JavaScript Code nodes in internal mode and Python in external mode at the same time?
No. N8N_RUNNERS_MODE is instance-wide, so every Code node on that instance uses the same mode. If you want the hardened Python runner with its allowlists, the whole instance moves to external mode, and the JavaScript tasks move with it.
Which n8n version do I need for external task runners?
n8n 1.111.0 or newer. The official docs require that version as the floor, and the runners image tag must match the n8n image tag exactly. Pin both tags in your compose file, for example n8nio/n8n:3.0.0 with n8nio/runners:3.0.0, so an upgrade never leaves them mismatched.
In queue mode, does each worker need its own runners sidecar?
Yes. The official docs state that in queue mode every worker needs its own sidecar container for task runners. If the main instance still runs manual executions (OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS is false), the main instance needs its own sidecar too.
Why does n8n log an internal-mode deprecation warning when I never set N8N_RUNNERS_MODE?
Because unset defaults to internal. Since n8n 3.0, startup logs the deprecation warning even when N8N_RUNNERS_MODE is absent entirely. The warning is n8n telling you to set the variable to external explicitly, which is also the cure.
Does the 60-second Code node timeout from n8n 3.0 apply in external mode?
Yes, the timeout applies to task runner tasks in both modes. External mode does not restore the old 5-minute limit. If long scripts die after about a minute on 3.0, the fix is N8N_RUNNERS_TASK_TIMEOUT, covered in our timeout fix guide.