
If your n8n Code nodes broke right after an upgrade and nothing about the code changed, this is why: n8n 2.0 turns task runners on by default, and the task runner is a separate process that does not read your n8n container's environment. The two variables that let a Code node import anything — NODE_FUNCTION_ALLOW_BUILTIN and NODE_FUNCTION_ALLOW_EXTERNAL — are read by the runner, not by n8n. They are still sitting on your n8n service, where they now do nothing. Copy them onto the runner and the node works again.
That single misplacement accounts for most of the "Code node stopped working" tickets we've picked up since 2.0 landed, and almost none of the guides mention it.
What actually changed in n8n 2.0
Before task runners, a Code node ran inside the main n8n process. Convenient, and a decent way to take down production — one runaway while loop or one memory leak in a JavaScript snippet, and the whole instance goes with it. We had a client instance in 2025 that fell over roughly once a fortnight because of a single Code node building an oversized array in memory. Nothing else on the box was at fault.
Task runners fix that by executing the Code node in a dedicated process that talks to n8n over a local broker on port 5679. If that process dies, the runner restarts and n8n keeps serving.
n8n has been shipping this for a while behind N8N_RUNNERS_ENABLED. The 2.0 change is that it's now the default, and N8N_RUNNERS_ENABLED=false is deprecated — set it and you get a warning today, and eventually nothing at all. So this is not optional, and the upgrade path is worth doing deliberately rather than discovering it at 2am.
The part that catches people: it's a process-boundary change, not a feature flag. Anything your Code node used to inherit from the n8n process — environment variables, allowlists, the module resolution path — now has to be granted to a different container.
The three errors you're most likely staring at
"Cannot find module 'crypto'" or any import failing
This is the allowlist problem. In external mode the runner is a separate container, and NODE_FUNCTION_ALLOW_BUILTIN / NODE_FUNCTION_ALLOW_EXTERNAL live on the runner. n8n's docs list them under the JavaScript task runner variables for exactly this reason, but if you upgraded an existing stack, yours are on the n8n service where you originally put them.
Move them. Both of them. NODE_FUNCTION_ALLOW_EXTERNAL also resolves against the runner image's node_modules, so a package you baked into a custom n8n image isn't visible to the runner unless you baked it into the runner image too.
"Task runner connection attempt failed with status code 403"
Looks like an auth problem, usually isn't. The runner gets a short-lived grant token to register with the broker. If the container takes longer to become ready than that token lasts — cold start, Kubernetes scheduling delay, a CPU-throttled host — the token has expired by the time it tries. You'll see it intermittently, which is the tell: a genuinely wrong token fails every single time, a race fails some of the time.
Three things to do, in order: give the runner container more CPU and memory so it boots faster, raise N8N_RUNNERS_TASK_REQUEST_TIMEOUT from its 60-second default, and upgrade to n8n 2.20.0 or later, which made the grant token TTL configurable rather than hardcoded.
Code nodes just hang, or die at five minutes
Two different defaults, both easy to miss:
N8N_RUNNERS_MAX_CONCURRENCYdefaults to5. That's five concurrent tasks per runner. If you're running a busy queue-mode instance, task six waits. It looks like a hang; it's a queue.N8N_RUNNERS_TASK_TIMEOUTdefaults to300seconds. A Code node that takes longer gets stopped and the runner restarts. If you have a genuinely long-running transform, raise it — or better, move that work out of a Code node entirely.
If you're sizing a queue-mode deployment around this, our post on how many n8n workers you actually need covers the concurrency math in more detail.
Internal mode vs external mode: pick external
N8N_RUNNERS_MODE takes two values and the difference matters more than the docs' one-liner suggests.
internal— n8n launches the runner as a child process, sharing the same uid and gid as n8n itself. You get crash isolation, so the memory-leak scenario is solved. You do not get meaningful security isolation, because the runner has the same identity and the same filesystem access as n8n. n8n's own documentation says it's not recommended for production. Fine for a laptop.external— a separate launcher application runs the runners on demand, normally as a sidecar container built from then8nio/runnersimage. This is where the isolation is real, and it's what you want on any instance that runs code you didn't personally write.
External mode is the only sensible choice once a client's team can edit workflows, which on our accounts is basically always. Internal mode means anyone with workflow edit rights is one Code node away from reading whatever n8n can read.
Here's the shape of a working external-mode compose file:
services:
n8n:
image: n8nio/n8n:2.20.0
environment:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=${RUNNER_TOKEN}
- N8N_NATIVE_PYTHON_RUNNER=true
ports:
- "5678:5678"
task-runners:
image: n8nio/runners:2.20.0
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
- N8N_RUNNERS_AUTH_TOKEN=${RUNNER_TOKEN}
# these belong HERE, not on the n8n service
- NODE_FUNCTION_ALLOW_BUILTIN=crypto,util
- NODE_FUNCTION_ALLOW_EXTERNAL=axios,luxon
- N8N_RUNNERS_MAX_CONCURRENCY=10
depends_on:
- n8n
Four details that break this if you get them wrong:
N8N_RUNNERS_BROKER_LISTEN_ADDRESSdefaults to127.0.0.1. Leave it and the broker only accepts connections from inside the n8n container, so a sidecar can never reach it. Set0.0.0.0and keep port5679off your host's published ports.- The image tags must match.
n8nio/n8n:2.20.0withn8nio/runners:latestwill connect and then fail in ways that waste an afternoon. External mode also needs n8n 1.111.0 or newer at minimum. N8N_RUNNERS_AUTH_TOKENmust be identical on both. If you don't set it, n8n generates a random one at startup and the runner has no way to know it.- In queue mode, every worker needs a runner too. Workers execute workflows, so each one needs its own runner sidecar and the same token.
The Python Code node has its own allowlists
If you're using the native Python runner (N8N_NATIVE_PYTHON_RUNNER=true), the JavaScript variables don't apply. Python has a separate, stricter set:
N8N_RUNNERS_STDLIB_ALLOW— which standard library modules a Code node may import, submodules included. Empty by default, soimport jsonfails until you allow it.N8N_RUNNERS_EXTERNAL_ALLOW— third-party packages.N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS— defaults tofalse, meaning a package you allowed can't pull in one you didn't. Leave it off unless something legitimately needs it.N8N_RUNNERS_BUILTINS_DENY— a deny list that already blockseval,exec,compile,open,getattr,globalsand friends. Sensible defaults. Don't loosen them to make a snippet work; rewrite the snippet.N8N_BLOCK_RUNNER_ENV_ACCESS— defaults totrue, so Python code can't read the runner's environment variables. That's the behaviour you want, and it's worth knowing before you try to pass a secret to a Code node that way.
These allowlists are also a real security control, not just plumbing. A Code node reachable from an AI agent is an execution path an attacker can aim at — the same problem we wrote about in prompt injection against n8n AI agents. Keep the allowlists tight.
What we set on every client instance
Across the self-hosted n8n instances we run, this is the baseline we standardised on after 2.0:
- External mode, always. No exceptions on anything client-facing.
- Both image tags pinned to the same explicit version, never
latest. - The auth token in the secret store, injected into both services from one variable.
N8N_RUNNERS_MAX_CONCURRENCYset to10as a starting point, then tuned against actual queue depth.N8N_RUNNERS_TASK_TIMEOUTleft at300on purpose — if a Code node needs more than five minutes, that's a design problem worth fixing, not a limit worth raising.- Allowlists written explicitly. Never
*onNODE_FUNCTION_ALLOW_BUILTIN, however tempting it is at 11pm. - Runner container logs shipped alongside n8n's, because a runner that fails to register is silent from n8n's side. Our error handling and monitoring setup covers how we wire that up.
The migration itself is maybe twenty minutes on a healthy instance. The reason it eats a whole day is that the symptom — "Code node broken" — points at the workflow, and the cause is one directory over in the compose file.
If you're weighing whether to keep self-hosting at all now that the runtime has more moving parts, we broke that decision down in self-hosted vs n8n Cloud for agencies. Task runners genuinely do make self-hosting a bit more work. They also mean one bad snippet stops taking your instance down with it, which is a trade we'd make every time.
For the full list of variables and defaults, n8n's task runner configuration reference is accurate and kept current.
Get a free automation audit
We run self-hosted n8n for agencies and operators who can't afford a workflow stack that falls over quietly. If your instance is throwing runner errors, if you're on 2.0 and unsure what else changed, or if nobody currently owns your automation infrastructure, we'll look at it.
Get a free automation audit — we'll review your n8n setup, flag what's going to break next, and tell you what we'd change. No pitch deck. You can also read more about how we handle n8n workflow automation for clients.