What breaks when you upgrade self-hosted n8n from 1.x to 2.x?
Four things. Execute Command node is disabled (the documented switch is NODES_EXCLUDE, not an include list; an empty array re-enables every disabled node, so name the ones you actually want back). WEBHOOK_URL is deprecated as of 2.35.0, use N8N_WEBHOOK_URL plus N8N_PROXY_HOPS=1. Task runners are on by default and N8N_RUNNERS_ENABLED is dead. Environment variables are blocked inside the Code node.
Key finding The documented variable is NODES_INCLUDE, with no N8N_ prefix. N8N_NODES_INCLUDE is not a real variable and is silently ignored.
Also updated 8 September 2026. Two smaller corrections in this article: the task runner example used untagged images while the text tells you to pin a version, and the end-to-end checklist listed the empty NODES_EXCLUDE array as an ordinary step without repeating the warning that it re-enables every disabled node.
Updated 8 September 2026. The backup snippet in this article was not runnable: it ran docker compose down and then docker compose exec, which cannot work because down removes the containers. The section is rewritten, split by database engine, and the failure checks are now tested rather than asserted.
n8n 2.0 went stable on 15 December 2025. The current stable line is 2.36.x. n8n supported 1.x for three months after the 2.0 release (security and bug fixes only, no new features) which puts the end of support at roughly 15 March 2026 (n8n blog, release notes 2.x). A 1.x instance today is unpatched software.
If you are still on 1.x, the upgrade is not a version bump. n8n described 2.0 as a security, reliability and performance release (“secure by default”) which in practice means several things that worked in 1.x are now off, and you have to turn them back on deliberately.
This is the list of what breaks on a self-hosted Docker instance, in the order you will hit them, with the exact environment variable for each.
Verified 28 August 2026. Every claim below links to the source. Where I am reasoning rather than quoting, I say so.
Before you upgrade: run the Migration Report
n8n ships a Migration Report in Settings → Migration Report, available to global admins on 1.119.0 and higher. It scans your existing workflows and tags each issue by severity; critical issues are the ones that will break execution (n8n blog).
Run it on 1.x, before you pull the 2.x image. After the upgrade you are debugging blind. This is the single step most upgrade posts skip, and it is the only one that tells you about your workflows rather than the general case.
Back up first, too: your .n8n volume holds the encryption key. Without it, every stored credential is unrecoverable, n8n’s own docs note the directory holds encryption keys and instance logs and should be persisted even when you use PostgreSQL (Install with Docker).
The order matters here, and the first version of this section got it wrong. docker compose down stops and removes the service containers, so a docker compose exec placed after it has nothing left to run in. Stop only n8n, keep the database container up, and check the real volume name first: Compose prefixes volumes with the project name, so a file that says n8n_data is usually <project>_n8n_data on disk.
docker compose ps &# service names as Compose knows them
docker volume ls &# the real volume name
VOL=yourproject_n8n_data &# use what you just saw, not n8n_data
Default SQLite install. The database sits inside the .n8n volume, so one archive covers the data and the encryption key together:
set -euo pipefail
docker compose stop n8n
docker run --rm -v "$VOL":/data -v "$PWD":/backup alpine \
tar czf /backup/n8n_data_$(date +%F).tar.gz -C /data .
tar tzf n8n_data_$(date +%F).tar.gz | grep -q config || { echo NO_CONFIG; exit 1; }
docker compose start n8n
PostgreSQL install. The database container has to stay running while it is dumped, which is exactly what the old snippet made impossible:
set -euo pipefail
docker compose stop n8n &# stop writes, leave postgres up
docker compose exec -T postgres pg_dump -U n8n -d n8n > n8n_db_$(date +%F).sql
gzip -9 n8n_db_$(date +%F).sql
docker run --rm -v "$VOL":/data -v "$PWD":/backup alpine \
tar czf /backup/n8n_data_$(date +%F).tar.gz -C /data .
docker compose start n8n
A backup file appearing is not a backup
We ran the failure case in a throwaway PostgreSQL 16 instance on 8 September 2026 rather than assert it. Pointing pg_dump at a database that does not exist, piped into gzip the way the old snippet did it, produced this:
- pipeline exit code 0
- a file on disk, 20 bytes
gzip -ton that file: passes- restoring it into a fresh database: exit code 0, zero tables
So the three checks people actually use, that the file exists, that it is a valid archive, and that the restore command succeeded, all report success on an empty backup. Two things did catch it. set -o pipefail turned the same pipeline into exit code 1. And the dump trailer is present in a real dump and absent in the empty one:
gzip -t n8n_db_$(date +%F).sql.gz
zcat n8n_db_$(date +%F).sql.gz | grep -q '^-- PostgreSQL database dump complete' \
|| { echo INCOMPLETE_DUMP; exit 1; }
Use grep, not tail -1. In the dump our test produced, that line is followed by a comment line and a \unrestrict line, so the trailer is not the last line of the file. A truncated archive is the one case gzip -t does catch on its own; we cut a good dump short and it failed as it should.
Restore into something disposable before you trust it. A separate empty database, never the live one:
createdb n8n_restoretest
zcat n8n_db_$(date +%F).sql.gz | psql -v ON_ERROR_STOP=1 -d n8n_restoretest
psql -d n8n_restoretest -tAc 'select count(*) from workflow_entity;'
psql -d n8n_restoretest -tAc 'select count(*) from credentials_entity;'
dropdb n8n_restoretest
Count rows, do not read exit codes. In our run the good dump restored 2 tables with 3 workflow rows and 2 credential rows; the empty dump restored 0 tables and still exited 0.
1. The Execute Command node is gone from the panel
This is the first thing most self-hosters notice, because the node simply is not in the node picker any more.
In 2.0, nodes that allow arbitrary command execution are disabled by default (n8n blog). The mechanism is NODES_EXCLUDE, whose default value is now:
["n8n-nodes-base.executeCommand", "n8n-nodes-base.localFileTrigger"]
Source: node environment variables.
The trap is the variable name. The documented variable is NODES_INCLUDE. no N8N_ prefix. A user filed a bug after setting N8N_NODES_INCLUDE=n8n-nodes-base.executeCommand and finding the node still missing; the issue was closed as working-as-expected (issue #23439). N8N_NODES_INCLUDE is not a real variable, so it was silently ignored, which is exactly how this wastes an afternoon, because nothing in the log tells you the name is wrong.
The fix is documented, just not where you would look for it. The NODES_EXCLUDE entry itself says: “To enable all nodes, specify NODES_EXCLUDE: "[]"“ (nodes env vars). You clear the exclusion list rather than adding to an include list:
environment:
- NODES_EXCLUDE=[]
In a .env file the same value is written NODES_EXCLUDE="[]". A community thread arrives at the same answer if you want to see someone else’s working config (thread).
Two notes worth taking seriously. First, [] re-enables both excluded nodes, including Local File Trigger, if you only want one back, list the other explicitly instead of emptying the array. Second, these nodes are excluded by default for a reason: anyone who can edit a workflow on your instance can run shell commands as the n8n user. If your instance has more than one editor, leave it excluded and move the shell work into a container you control.
2. WEBHOOK_URL is deprecated, webhooks show the wrong host
If your production webhook URLs come back as http://localhost:5678/webhook/... or with an internal port glued on, this is the cause.
WEBHOOK_URL is deprecated as of n8n 2.35.0, replaced by N8N_WEBHOOK_URL. Behind a reverse proxy you also need to tell n8n how many proxy hops to trust (configure webhook URLs with a reverse proxy):
environment:
- N8N_WEBHOOK_URL=https://n8n.example.com/
- N8N_PROXY_HOPS=1
The docs are explicit that N8N_HOST, N8N_PROTOCOL and N8N_PORT. The three variables every 1.x-era tutorial tells you to set, are components n8n assembles internally and do not work correctly behind a reverse proxy. Setting the full URL manually is the fix, not a workaround.
N8N_PROXY_HOPS=1 is right for the common case of one proxy in front of n8n (Caddy, nginx, Traefik). If you are behind Cloudflare and a local proxy, that is two hops.
The symptom is worth naming because it is confusing: this bites you at OAuth time, not at webhook time. The redirect URL n8n hands to a provider is built from the same setting, so a wrong value shows up as a failing OAuth callback long before anyone posts to your webhook (community report).
3. Task runners are on by default, and N8N_RUNNERS_ENABLED is dead
In 2.0, Code node executions run in isolated task runners by default (n8n blog). The variable you were told to set in 1.x, N8N_RUNNERS_ENABLED=true, is deprecated from 2.0 and no longer needed (set up task runners).
Confusingly, n8n’s own Docker install page still shows N8N_RUNNERS_ENABLED=true in its example docker run (checked 2026-08-28). It is harmless, but do not take its presence as a sign you need it.
What you choose now is the mode:
N8N_RUNNERS_MODE=internal. The runner launches as a child process of n8n. Simplest. The docs do not recommend it for production.N8N_RUNNERS_MODE=external. A separaten8nio/runnerscontainer. Recommended for production.
External mode, per the docs:
services:
n8n:
image: n8nio/n8n:2.35.0 # pin the same tag you tested
environment:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=${RUNNER_TOKEN}
task-runners:
image: n8nio/runners:2.35.0 # keep runner and n8n on one version
environment:
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
- N8N_RUNNERS_AUTH_TOKEN=${RUNNER_TOKEN}
- N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT=15
Pin both images to the same tag. The broker protocol is not something to run mismatched versions across, and n8n’s own example pins both sides.
The part that costs money: external mode is a second long-running Node process. On a 1 GB VPS that was comfortable under 1.x, you are now fitting n8n, a runner and (if you use it) Postgres into the same memory. n8n does not publish a hard minimum spec. I could not find one, and any guide quoting an official figure is quoting something that is not there. But the direction is not in doubt: 2.x with external runners needs more RAM than 1.x did, and 1 GB is where people start seeing the container OOM-killed mid-execution. Budget 2 GB, and price that box against n8n Cloud in our 12-month cost model.
4. Environment variables are blocked inside the Code node
2.0 blocks Code nodes from reading the host’s environment variables (n8n blog). If a workflow did process.env.SOME_API_KEY, it now gets nothing.
This one is not a config bug to route around, it is the point of the release. The right fix is to move those values into n8n credentials, which is where they should have been: credentials are encrypted at rest with your instance key, and they do not leak into execution logs the way an inlined env read can.
If you have a genuine reason to import a built-in or external module inside the Code node, those are separate switches and they are still there: NODE_FUNCTION_ALLOW_BUILTIN and NODE_FUNCTION_ALLOW_EXTERNAL, both empty by default (nodes env vars).
5. EACCES: permission denied, open '/home/node/.n8n/config'
Not new in 2.0 (this one is older than most n8n installs) but the upgrade is when people recreate their volumes and meet it.
The cause is a UID mismatch. n8n runs as the node user inside the container (UID 1000). If you bind-mount a host directory (-v ~/.n8n:/home/node/.n8n) owned by a different UID, the container cannot write its config file (issue #1240, still recurring in #11102 on 1.61.0).
Two fixes, in order of preference:
# Best: use a named Docker volume and never think about UIDs again
docker volume create n8n_data
# ... -v n8n_data:/home/node/.n8n
# If you insist on a bind mount, hand it to UID 1000
sudo chown -R 1000:1000 ~/.n8n
Related, and worth setting deliberately: N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true appears in n8n’s documented Docker command. It makes n8n enforce restrictive permissions on its own settings file. Keep it on; if it fires an error, that is the setting telling you your config file is world-readable, which on a shared box it should not be.
The upgrade, start to finish
# 1. On 1.x: Settings → Migration Report. Fix every critical item first.
# 2. Back up the volume AND the database (commands at the top of this article).
# 3. Edit compose: pin a 2.x tag, do not use :latest for a major upgrade.
# 4. Add the variables you now need:
# NODES_EXCLUDE=["n8n-nodes-base.localFileTrigger"]
# ^ only if you need Execute Command back, and only on a single-editor
# instance. An empty [] re-enables every disabled node. See section 1.
# N8N_WEBHOOK_URL=https://your.domain/
# N8N_PROXY_HOPS=1
# N8N_RUNNERS_MODE=internal|external
# 5. docker compose pull && docker compose up -d
# 6. docker compose logs -f n8n # read it; deprecation warnings name the variable
# 7. Re-run one workflow of each kind: a webhook, a Code node, a scheduled trigger.
Step 7 is the one to not skip. The three failure modes in this article each surface in a different kind of workflow, and an instance where the editor loads fine can still have every webhook pointing at localhost.
What I could not verify
Being straight about the edges of this:
- No official minimum hardware spec exists. n8n’s docs do not publish one. The 2 GB figure above is my reasoning from what 2.x runs as separate processes, not a vendor requirement. Sizing guides that quote an official minimum are inventing it.
- n8n’s Docker install page still shows a deprecated variable as of 2026-08-28. If that changes, the page is right and this note is stale.
FAQ
Do I have to upgrade to 2.x?
1.x received security and bug fixes for three months after the 2.0 release. A window that closed around 15 March 2026 (n8n blog). Running 1.x today means running software that gets no patches at all, exposed to the internet if your webhooks are.
Can I skip straight from an old 1.x to 2.36?
The Migration Report only exists from 1.119.0. Upgrade to a recent 1.x first so you can run it, then go to 2.x. Two hops, one of which tells you what will break.
Will my credentials survive?
Yes, if the .n8n volume with the encryption key survives. That is the whole reason for the backup step.
Is Cloud affected by any of this?
No. n8n Cloud handles the migration. Everything in this article is a self-hosting cost, which is the honest thing to weigh when you compare the €20/month plan against a $6 VPS.
This page links to each vendor’s own pricing page. No affiliate links are used at this time. If you buy hosting through one, this site earns a commission at no extra cost to you. Prices and specifications in this article were checked on the dates stated and are linked to their sources so you can verify them yourself.
Related: The cheapest VPS that actually runs n8n 2.x, verified prices, checked this month.
Task runners raise the memory floor, which makes the price of RAM the deciding factor when you size a box. See what a GB of RAM costs across VPS providers.
Upgrades are not the only thing that fails quietly. Four more silent failures from self-hosting on Docker and Caddy, with the diagnosis for each.