Guide

Upgrading to n8n 2.0: The Complete Breaking-Changes Checklist

Shannon AtkinsonSeptember 6, 202620 min read
Upgrading to n8n 2.0: The Complete Breaking-Changes Checklist

This post is adapted from Chapter 33 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

n8n 2.0 shipped on 8 December 2025. If you are still on any 1.x release, this guide takes you from where you are to a pinned 2.x release on a standard Docker Compose stack.

2.0 is a cleanup release, not a feature release. Almost everything in it is one of three things:

  1. Security defaults that flipped. Opt-in hardening on 1.x is on by default in 2.0. Workflows relying on the loose default stop working until you fix the workflow or explicitly opt back out.
  2. Legacy code paths removed. MySQL, the SQLite legacy driver, in-memory binary data, the Start node, n8n --tunnel, Pyodide Python, four nodes for services that no longer exist.
  3. Renames and replacements. Publish/Unpublish instead of the active toggle, publish:workflow instead of update:workflow, stable/beta instead of latest/next.

So this is mostly a configuration migration. Most of the work is in your .env and in a handful of workflows, and n8n ships a scanner that tells you which ones.

One thing 2.0 does not change: there is still no basic auth. N8N_BASIC_AUTH_USER and N8N_BASIC_AUTH_PASSWORD were removed in 1.0, and 2.0 does not bring them back. If those variables are still sitting in your .env, delete them.

Upgrade flow: pin current 1.x version, back up including the encryption key, restore onto a throwaway copy, review Settings - Migration Report by severity, refresh until clean, bump both image tags, pull and up -d, watch migrations, smoke test, and restore the pre-upgrade dump if unhealthy

Prerequisites

  • A running self-hosted n8n on any 1.x release, with shell access to the host.
  • Docker Compose v2 (docker compose, with a space).
  • PostgreSQL as the n8n database. On MySQL or MariaDB, see section 7 — you must move off it before upgrading.
  • A working backup and a tested restore.
  • Your N8N_ENCRYPTION_KEY, stored somewhere other than the server you are about to change.
  • Global admin or owner access to the editor, so you can open the Migration Report.

1. Why 2.0 Matters

Your current version stops getting fixes once 2.x is the supported line, and n8n 3.0 lands in October 2026 with a further round of removals that assumes you already made this jump — see our guide to preparing for n8n 3.0. Going 1.x straight to 3.x later means absorbing two sets of breaking changes at once, without the 2.0 migration scanner. Take the smaller step now.

Three changes drive most of the rest:

  • Every Code node now runs on a task runner. Optional on 1.x, mandatory on 2.0. That one change causes three separate breaking changes: $evaluateExpression stops working, the runner binary leaves the main image, and Pyodide Python is replaced by native Python. See our task runners guide for the full setup.
  • Sub-workflows that wait now return their real output. A long-standing wrong answer was fixed, so workflows written around the wrong answer break.
  • Active/inactive becomes Publish/Unpublish, with versioning.

2. Pre-Flight Checklist

Do all four, in order.

Pin your current version. If your compose file has a floating tag or none at all, you cannot roll back to what you were running, because you do not know what you were running:

docker compose exec n8n n8n --version
docker compose images n8n

Put that exact version in compose.yml and run docker compose up -d to confirm nothing changes. You now have a known starting point.

Take a full backup, including the encryption key. A database dump alone is not a backup of n8n. Credentials are encrypted with N8N_ENCRYPTION_KEY, and a dump restored without it gives you rows you cannot decrypt.

# Database
docker compose exec -T postgres pg_dump -U n8n -F c n8n > n8n-pre-2.0-$(date +%F).dump

# n8n data volume: settings file, filesystem binary data
docker run --rm \
  -v n8n_data:/data -v "$(pwd)":/backup \
  alpine tar czf /backup/n8n-data-pre-2.0-$(date +%F).tar.gz -C /data .

# Configuration, including the key
cp .env "env-pre-2.0-$(date +%F).bak"

Store the .env copy encrypted. It holds the key and the database password.

Test on a copy. Bring up a second stack under a different project name and restore the dump into it:

docker compose -p n8n-upgrade-test up -d postgres
docker compose -p n8n-upgrade-test exec -T postgres \
  pg_restore -U n8n -d n8n --clean --if-exists < n8n-pre-2.0-2026-09-05.dump

Give the copy the same N8N_ENCRYPTION_KEY but a different N8N_HOST and no public webhook URL, so nothing in it fires against real systems. Leave every workflow unpublished.

Read the Migration Report on the copy. That is the next section.

3. Using the Built-In Migration Report

n8n 1.x ships a scanner for exactly this upgrade. Go to Settings → Migration Report. It is visible to global admins only; a member account will not see the entry.

The top of the page reads "X out of Y workflows are compatible with n8n 2.0." Your job is to make X equal Y and to clear the instance issues as well.

Two tabs:

  • Workflow Issues — breaking changes affecting specific workflows. Each row gives an issue title, a severity badge, a description, a documentation link, and a count of affected workflows. Click the "N workflows affected" count for the detail page: workflow name (click to open the editor), published state, the nodes affected (click one to open the editor on that node), total executions, last executed, last updated. The execution counts matter — a Critical issue on a workflow with 40,000 executions is your first job; one with two executions from last March may be easier to delete than to fix.
  • Instance Issues — configuration changes that apply instance-wide. Same fields, minus the workflow count. These are the environment variables in sections 6, 7 and 8.

Severities mean what they say:

SeverityMeaningWhen to fix
CriticalThe workflow or instance will fail after upgradingBefore you upgrade. No exceptions.
MediumMay cause unexpected behavior, or needs attention soonBefore you upgrade, unless you have a reason.
LowDeprecations and minor changes that will not break anythingAfter the upgrade is fine.

Follow the recommended order of work literally:

  1. Initial assessment. Read the summary and skim both tabs before changing anything.
  2. Sort by severity. Critical, then Medium, then Low.
  3. Fix workflow issues. Open each issue, read its linked documentation, edit each affected workflow, test on the copy.
  4. Address instance issues. Edits to .env or the compose environment block. Make them on the copy first.
  5. Verify. Click Refresh to re-scan; if your build has no Refresh button, reloading the page re-scans too. Confirm the fixed issues are gone and the compatibility count matches your workflow count.
  6. Proceed. Two empty tabs means the instance is ready.

A clean report is not a guarantee. The scanner sees workflow JSON and instance configuration; it cannot see what your Code nodes do at runtime, and it cannot see a .env quoting problem. It narrows the search — it does not replace the smoke test in section 12.

4. The Breaking-Change Checklist

Sections 5 through 12 in one table, grouped the way the n8n docs group them.

GroupChangeAffected ifAction
BehaviorWaiting sub-workflow return valuesParent waits for a child that hits a Wait node over 65s, webhook, form, or HITL nodeRework the parent to consume the child's real output
BehaviorStart node removedAny workflow still has a Start nodeManual Trigger, or Execute Workflow Trigger for sub-workflows; delete if disabled
BehaviorPublish/Unpublish replaces active toggleEveryoneNo action; learn the new UI and its versioning
BehaviorSpontit, crowd.dev, Kitemaker, Automizy removedAny workflow uses oneRemove or replace the node
SecurityN8N_BLOCK_ENV_ACCESS_IN_NODE=trueCode node reads process.envMove the value into a credential, or set it to false
SecuritySettings file must be 0600Config file permissions are looserchmod 600, or N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false
SecurityTask runners on by defaultAny Code nodeConfigure external runners; N8N_RUNNERS_ENABLED is deprecated
Security$evaluateExpression dead in Code nodeA Code node calls itRewrite in JS, or pre-evaluate in a Set node
SecurityRunner removed from n8nio/n8n imageYou ran external runners from the main imageUse n8nio/runners at the same version
SecurityPyodide Python removedAny Python Code node or toolExternal runners plus N8N_NATIVE_PYTHON_RUNNER=true; rewrite the code
SecurityExecuteCommand and LocalFileTrigger excludedA workflow uses themAdjust NODES_EXCLUDE, or replace the node
SecurityN8N_SKIP_AUTH_ON_OAUTH_CALLBACK=falseYou reconnect OAuth credentialsTest each OAuth credential; keep auth on
SecurityN8N_RESTRICT_FILE_ACCESS_TO=~/.n8n-filesFile nodes touch other pathsMove the files, or widen the variable deliberately
SecurityN8N_GIT_NODE_DISABLE_BARE_REPOS=trueGit node uses a bare repoSet to false only if you need it
DataMySQL/MariaDB droppedDB_TYPE=mysqldb or mariadbMove to Postgres while still on 1.x
DataSQLite legacy driver removedDB_TYPE=sqlitePooled driver is now default; set DB_SQLITE_POOL_SIZE
DataIn-memory binary mode removedN8N_DEFAULT_BINARY_DATA_MODE=defaultPick filesystem, database, or s3; drop N8N_AVAILABLE_BINARY_DATA_MODES
Configdotenv upgraded.env uses backticks, #, or odd quotingRe-quote values; verify parsing
Confign8n --tunnel removedDev setups onlyUse ngrok, localtunnel, or Cloudflare Tunnel
ConfigQUEUE_WORKER_MAX_STALLED_COUNT removedQueue modeDelete the variable
ConfigN8N_CONFIG_FILES removedYou load JSON config filesMove to env vars, .env, or _FILE variables
CLIupdate:workflow replacedScripts or CI call itpublish:workflow / unpublish:workflow
Hooksworkflow.activeChange deprecatedCustom external hooksUse workflow.published
Channelslatest/nextstable/betaYou track a floating tagPin an exact version instead

5. Behavior Changes

Sub-workflow return values when the child waits. On 1.x, if a parent called a sub-workflow that entered the waiting state — a Wait node with a timeout over 65 seconds, a webhook call, a form submission, or a human-in-the-loop node such as Slack approval — and the parent was set to wait for completion, the parent received the child's input echoed back as its output. That was wrong. On 2.0 the parent receives the child's real output.

How to tell: the Migration Report flags it. Manually, look for Execute Workflow nodes set to wait, and check whether the child contains a Wait, webhook, form, or HITL node.

What to do: rework the parent to consume the child's actual output. This is what unlocks the useful pattern — a sub-workflow that asks a human to approve or decline, and a parent that branches on the answer.

Start node removed. Replace it based on how the workflow runs: a Manual Trigger for manual executions; an Execute Workflow Trigger if another workflow calls this one as a sub-workflow (then publish it); if the Start node is disabled, delete it.

Publish/Unpublish replaces the active toggle. Activate/Deactivate is now Publish/Unpublish, backed by versioning, so you edit freely and publish deliberately. Nothing to fix — but see section 14 about workflows appearing unpublished after the upgrade.

Removed nodes. Spontit, crowd.dev, Kitemaker and Automizy are gone because the services behind them are gone. Any workflow containing one errors. Remove or replace.

6. Security

Every item here is a default that flipped. Each has an escape hatch, and each escape hatch gives up the protection — say what you are giving up before you use one.

N8N_BLOCK_ENV_ACCESS_IN_NODE=true. Code nodes can no longer read the container's environment variables. Setting it back to false means anyone who can edit a workflow can read every secret in your environment block, including N8N_ENCRYPTION_KEY and the database password. Move the values into credentials instead.

Settings file permissions. The config file in the n8n data directory must be 0600 — owner read/write only, the way SSH treats private keys — and n8n refuses to start otherwise. Rehearse on 1.x by setting N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true. On a filesystem that cannot express Unix permissions (a Windows bind mount, some network volumes) set it to false.

Task runners on by default. Every Code node execution runs on a task runner. N8N_RUNNERS_ENABLED is deprecated in 2.0 — on 1.x it was how you turned runners on, and setting it to true there is how you rehearse. On 2.0 what matters is N8N_RUNNERS_MODE: internal spawns a child process and is not for production, external runs the sidecar. The reference stack uses external.

$evaluateExpression in Code nodes. Runners run in secure mode, which disables evaluating strings as code — the mechanism expressions rely on. $evaluateExpression() inside a Code node now returns null or errors. Expressions in ordinary node fields, such as Edit Fields (Set), are unaffected. Fix it in this order: write the logic in JavaScript directly; evaluate the expression in a Set node before the Code node and read the result from the incoming item; or, last resort, set N8N_RUNNERS_INSECURE_MODE=true, which turns the runner's security measures off instance-wide. Treat that as temporary — the method may be removed outright in a future version.

The runner moved out of the main image. n8nio/n8n no longer contains the task runner binary for external mode. Use the separate n8nio/runners image, pinned to the same version as n8n.

Pyodide Python removed. The browser-style Python runtime is replaced by native Python, which runs only on external task runners; set N8N_NATIVE_PYTHON_RUNNER=true on the n8n service. Native Python drops the Pyodide-era conveniences — no _input built-in, no dot-access notation — so existing Python Code nodes need rewriting, not just re-pointing. Native Python tools get _query, the input string the AI Agent passed in.

ExecuteCommand and LocalFileTrigger disabled. Both are excluded by default through NODES_EXCLUDE, because both hand workflow editors arbitrary command execution and filesystem access on the host. If you need one, remove just that entry. NODES_EXCLUDE="[]" re-enables everything and is the blunt option.

OAuth callbacks require authentication. N8N_SKIP_AUTH_ON_OAUTH_CALLBACK now defaults to false. Set it to false on 1.x and reconnect one OAuth credential, to confirm your proxy passes session cookies through the callback path.

N8N_RESTRICT_FILE_ACCESS_TO has a default. It is now ~/.n8n-files, constraining the Read/Write File and Read Binary Files nodes. The reference stack already mounts a named volume there. Widen it only to paths you intend workflows to reach.

N8N_GIT_NODE_DISABLE_BARE_REPOS=true. The Git node blocks bare repositories. Set it to false only if a workflow depends on them.

7. Data

MySQL and MariaDB are dropped. Deprecated since 1.0, now removed. This is the one change you cannot fix afterwards: a 2.0 container pointed at MySQL will not start, and there is no in-place conversion. Migrate to PostgreSQL while still on 1.x using n8n's database migration tooling, verify the instance runs correctly on Postgres, take a fresh backup, and only then upgrade. The MySQL node is unaffected — this is only n8n's own storage backend.

SQLite legacy driver removed. The pooled driver is now the default and only SQLite driver. It uses WAL mode with one write connection and a pool of read connections, and n8n's benchmarks put it up to ten times faster. DB_SQLITE_POOL_SIZE defaults to 2; enable pooling on 1.x by setting it above 0. For anything beyond a laptop, use Postgres.

In-memory binary data mode removed. N8N_DEFAULT_BINARY_DATA_MODE=default, which held execution binary data in RAM, is gone. The options are filesystem (default in regular mode), database (default in queue mode) and s3. N8N_AVAILABLE_BINARY_DATA_MODES is removed entirely, so the mode is set solely by N8N_DEFAULT_BINARY_DATA_MODE. Delete the old variable and make sure the host has disk for data that used to live in memory.

8. Configuration and Environment

dotenv upgraded. n8n moved from dotenv 8.6.0 to a current release, and parsing changed. Three things to check in .env:

  • Values containing backticks must be wrapped in single or double quotes.
  • # starts a comment. A password containing # needs quoting, or everything after it disappears.
  • Multiline values are now supported, so a stray unbalanced quote can swallow the following lines instead of erroring on its own.

Read .env line by line before you upgrade. A silently truncated password is a miserable thing to debug at 2am.

n8n --tunnel removed. Use ngrok, localtunnel, or Cloudflare Tunnel, and set WEBHOOK_URL and N8N_EDITOR_BASE_URL to the tunnel hostname.

QUEUE_WORKER_MAX_STALLED_COUNT removed. The variable and the Bull stalled-job retry behind it are gone; they were confusing and unreliable. Delete it. n8n no longer retries stalled jobs automatically. Use N8N_GRACEFUL_SHUTDOWN_TIMEOUT to give workers time to finish on shutdown — see our queue mode guide for the full worker lifecycle.

N8N_CONFIG_FILES removed. JSON config files are no longer loaded. Move that configuration into environment variables, .env, or _FILE-suffixed variables pointing at a file holding a single secret.

9. CLI and Workflow

update:workflow is replaced by two commands with clearer intent:

# Publish one workflow, optionally a specific version
docker compose exec n8n n8n publish:workflow --id=<workflow-id>
docker compose exec n8n n8n publish:workflow --id=<workflow-id> --versionId=<version-id>

# Unpublish one workflow, or all of them
docker compose exec n8n n8n unpublish:workflow --id=<workflow-id>
docker compose exec n8n n8n unpublish:workflow --all

The asymmetry is deliberate: publish:workflow has no --all, so you cannot mass-publish production by accident, while unpublish:workflow keeps it, because turning everything off in a hurry is a legitimate thing to want. Grep your CI pipelines, deploy scripts and cron jobs for update:workflow before you upgrade.

10. External Hooks

If you run custom frontend external hooks, workflow.activeChange and workflow.activeChangeCurrent are deprecated, replaced by a single workflow.published hook that fires whenever any version of a workflow is published. Update your hook code to the new name. If you do not use external hooks, skip this.

11. Release Channels

The channels are renamed: latest becomes stable, next becomes beta, on both Docker Hub and npm. stable is the newest stable release; beta the newest experimental one. The old tags still resolve for now and will be removed in a future major version.

Renaming them does not make floating tags safe. Pin an exact version everywhere, so docker compose pull never surprises you and a rollback has something concrete to return to. If you must track a channel on a non-production instance, use stable.

12. Performing the Upgrade

You have a clean Migration Report on the copy, a fresh backup, and a .env you have re-read. On the reference stack the upgrade is two tag changes in compose.yml:

n8n:
  image: n8nio/n8n:2.38.1

runners:
  image: n8nio/runners:2.38.1

Both must be the same version — a runners sidecar on a different version than n8n is unsupported and fails in confusing ways. If your 1.x stack has no runners service at all, add one now, because on 2.0 every Code node needs it.

Pull first, so the download does not happen inside the restart window, then bring the stack up and watch the logs. n8n runs its database migrations on first start of the new version, and on a large executions table this takes minutes:

docker compose pull
docker compose up -d
docker compose logs -f n8n

You want migration lines in sequence, then a normal startup banner. Do not interrupt this. A half-applied migration is a restore, not a retry.

Verify with something that exercises the new machinery, not just the editor loading:

  1. Log in with your existing owner account.
  2. Open a credential and confirm the fields are populated — that proves the encryption key survived.
  3. Build a throwaway workflow: Manual Trigger → Code node returning [{ json: { ok: true } }], and execute it. A Code node that runs proves the task runner is connected; one that hangs or errors means the sidecar is not talking to the broker.
  4. Check docker compose logs runners.
  5. Publish one low-risk real workflow, watch it run once, then publish the rest.

13. Rollback Plan

Be clear about what rollback means. n8n's database migrations are not reversible. Once 2.0 has migrated the schema, pointing a 1.x container at that database does not work, and there is no downgrade path in the software. A rollback is a restore.

That is why section 2 insisted on the pre-upgrade dump.

# 1. Stop everything
docker compose down

# 2. Put the old tags back in compose.yml, then start only Postgres
docker compose up -d postgres

# 3. Restore the pre-upgrade dump over the migrated database
docker compose exec -T postgres \
  pg_restore -U n8n -d n8n --clean --if-exists < n8n-pre-2.0-2026-09-05.dump

# 4. Restore the data volume if anything in it changed
docker run --rm \
  -v n8n_data:/data -v "$(pwd)":/backup \
  alpine sh -c "rm -rf /data/* && tar xzf /backup/n8n-data-pre-2.0-2026-09-05.tar.gz -C /data"

# 5. Restore .env, including the original N8N_ENCRYPTION_KEY
cp env-pre-2.0-2026-09-05.bak .env

# 6. Start the old version
docker compose up -d

Two consequences follow. Everything between the backup and the rollback is lost — executions, edits, new credentials — so take the dump immediately before the upgrade, not the night before. And decide your rollback deadline in advance: "we roll back if it is not healthy in 30 minutes" is a decision to make while calm, because after 30 minutes of production traffic the restore costs you 30 minutes of data.

14. Troubleshooting Common Issues

n8n refuses to start, complaining about config file permissions. 2.0 enforces 0600 on the settings file. Fix it inside the volume:

docker compose run --rm --user root --entrypoint sh n8n \
  -c "chmod 600 /home/node/.n8n/config && chown node:node /home/node/.n8n/config"

If the volume is a bind mount on a filesystem that cannot express Unix permissions, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false and understand you are keeping the config file readable by anything on that host.

Code node errors after the upgrade. In order: an error mentioning process.env, or undefined where a secret should be, is N8N_BLOCK_ENV_ACCESS_IN_NODE=true — move the value into a credential. An error mentioning $evaluateExpression is section 6. If the node hangs, times out, or reports no runner available, the sidecar is the problem: confirm the runners service is running, that both images are the same version, that N8N_RUNNERS_AUTH_TOKEN is identical on both, and that N8N_RUNNERS_TASK_BROKER_URI points at the right service name on port 5679. In queue mode, every worker needs its own runners sidecar. If it is a Python Code node, it is the Pyodide removal — you need external runners, N8N_NATIVE_PYTHON_RUNNER=true, and rewritten code.

Workflows that were active now show as unpublished. Check before you panic: the toggle became Publish/Unpublish, so the label changed for everything, and a workflow still live now reads "Published". If one really is unpublished, the usual cause is that it contained something 2.0 rejects — most often a Start node. Open it, replace the Start node with a Manual Trigger or an Execute Workflow Trigger, save, publish. The Migration Report stays available after the upgrade; check it on the new instance too.

Credentials are undecryptable after a restore. Credentials exist and open but show empty or garbled fields, and nodes fail to authenticate. The cause is almost always that N8N_ENCRYPTION_KEY is not the value it was when those credentials were saved — typically because the key was never set explicitly, so n8n generated one into the data volume, and the volume was replaced while the database was restored. Restore the original .env and the original data volume together, and confirm:

docker compose exec n8n printenv N8N_ENCRYPTION_KEY

If the key is genuinely lost, the credentials are not recoverable. Delete and re-enter them, then set the key explicitly in .env so this cannot happen twice.

Further reading


Hit a snag mid-upgrade? Post your Migration Report or your broken workflow 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