Most guides treat moving off n8n Cloud as a copy-paste job: export your workflows, import them, done. That model is wrong, and following it is how you end up with dead triggers and credentials that refuse to decrypt. Moving from n8n Cloud to a self-hosted instance is not a workflow transfer. It is a re-binding exercise. The workflow JSON is the only part that moves cleanly. Everything those workflows depend on - encrypted credentials, OAuth authorizations, production webhook URLs, execution history, internal IDs, and the runtime defaults Cloud configured for you - either cannot be exported at all or has to be rebuilt on the new instance.
So “without breaking workflows” cannot mean “nothing changes,” because two of those breakages are structural and unavoidable. The achievable goal is different, and better: a controlled, parallel cutover where every breakage is known in advance and fixed on purpose, while Cloud keeps serving production until the self-hosted instance is verified. No surprises, no data loss, no downtime.
This guide targets n8n 2.x. As of June 2026 the current stable line is 2.23.x; check the release notes for the exact latest before you start. It assumes both your Cloud instance and your target are on the 2.x line, and that you are comfortable with Docker Compose, environment variables, a reverse proxy, and DNS.
Why “zero breakage” is the wrong target
Two things break no matter how careful you are. Naming them up front is what lets you plan around them instead of discovering them in production.
Credentials never transfer. n8n blocks credential export from Cloud for security reasons. There is no flag, no admin toggle, and no database access that gets the credential values out. You will re-enter every one by hand on the new instance.
Production webhook URLs change. A production webhook URL is derived from the instance host and base URL. On Cloud that base is your Cloud subdomain; on self-hosted it is your own domain. The path after the host stays the same, the origin does not, so every external system calling the old URL has to be repointed.
Accept those two and the rest of this guide is about containment: rebuild each dependency deliberately, verify, and switch over in an order that never leaves a trigger firing twice or going dark.
The real model: portable definitions vs non-portable bindings
Before any commands, sort everything in your setup into two buckets. That split is the whole job.
Moves cleanly
Node configuration, connections, node settings, and expressions, including hardcoded and static values, all live inside the workflow JSON and import without change. The supported migration path is exactly this: export the workflow files, import them.
Tags and folder organization are a softer case. Treat them as verify-after-import: confirm tags came across, and be ready to recreate your folder structure rather than assuming a bulk export preserves it.
Must be rebuilt
Credentials, OAuth authorizations, production webhook URLs, execution history, internal workflow and credential IDs, published state, and the runtime defaults Cloud set for you. Each fails in its own way, and the rest of this guide takes them one at a time.
The trick that does not apply here
Most self-hosting guides tell you to copy N8N_ENCRYPTION_KEY and the database across to carry credentials. That is a self-hosted-to-self-hosted procedure. It works because both ends share the encryption key and you can read the source database. Cloud gives you none of that: no encryption key, no database, no shell, no CLI. The n8n export:credentials --decrypted command those guides lean on runs against a database you cannot reach on Cloud. Do not spend time hunting for an export that does not exist.
| Item | Moves with workflow export? | What you must do |
|---|---|---|
| Node config, connections, settings | Yes | Nothing |
| Expressions, static and hardcoded values | Yes | Nothing |
| Tags, folders | Partly | Verify, recreate folders if needed |
| Credentials (API keys, passwords) | No, export blocked on Cloud | Re-enter by hand |
| OAuth authorizations | No | Recreate credential, register new redirect URL, re-authorize |
| Production webhook URLs | No, bound to Cloud domain | Set WEBHOOK_URL, update external callers |
| Execution history | No | Accept loss, or keep Cloud read-only for reference |
| Internal IDs | Yes, and can overwrite on import | Import into a clean instance or change IDs |
| Published state | No, imports arrive unpublished | Publish deliberately during cutover |
| Task runners, timezone, binary-data mode | No, Cloud-managed | Configure on the new instance |
Stand up the target instance first
Configure the new instance correctly before you import anything. Get the encryption key and webhook URL wrong here and you manufacture the exact failures this guide is about.
Encryption key
Set N8N_ENCRYPTION_KEY explicitly before the first launch. If you do not, n8n generates one on first start and writes it into the .n8n config file. A later redeploy on a fresh volume, or any deploy without that exact key, makes every stored credential undecryptable, with no recovery. Generate one and keep it in your secret manager:
openssl rand -hex 32Warning: The encryption key is the single most important value to back up. There is no recovery path. If you lose it, re-entering every credential by hand is your only option. See Set a custom encryption key.
Webhook base URL
Set WEBHOOK_URL to your public domain with a trailing slash so the editor displays correct URLs and external services register the right endpoints. Behind a reverse proxy, also set N8N_PROXY_HOPS to the number of proxies in front of n8n (1 for a single reverse proxy, 2 if a CDN or load balancer sits in front of that). Details in Configure webhook URLs with reverse proxy.
Timezone
Set GENERIC_TIMEZONE to match your Cloud instance, or scheduled triggers shift relative to where they ran before.
Database
SQLite is the default; Postgres is the production choice. Decide now, because moving SQLite to Postgres later uses a separate CLI path and an empty target database, covered below. Note that MySQL and MariaDB are no longer supported on 2.0, so Postgres or SQLite are your only options.
Authentication
Create the owner account through the in-app setup screen. There is no way to skip the login screen: basic auth, external JWT, and the N8N_USER_MANAGEMENT_DISABLED variable were all removed in 1.0. SMTP is optional and only needed for user invites and password resets.
Task runners
Plan for task runners now, because on 2.x Code nodes do not execute without them. The details are in the runtime section below; the minimum is one environment variable in your compose file.
A single-instance starting point that gets the load-bearing settings right:
services: n8n: image: n8nio/n8n:2.23.3 restart: always ports: - "5678:5678" environment: - N8N_HOST=n8n.example.com - N8N_PROTOCOL=https - WEBHOOK_URL=https://n8n.example.com/ - N8N_PROXY_HOPS=1 - GENERIC_TIMEZONE=Europe/Kyiv - N8N_ENCRYPTION_KEY=REPLACE_WITH_OUTPUT_OF_OPENSSL_RAND_HEX_32 - N8N_RUNNERS_ENABLED=true - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_DATABASE=n8n - DB_POSTGRESDB_USER=n8n - DB_POSTGRESDB_PASSWORD=REPLACE_WITH_STRONG_DB_PASSWORD volumes: - n8n_data:/home/node/.n8n depends_on: - postgres
postgres: image: postgres:18.4 restart: always environment: - POSTGRES_DB=n8n - POSTGRES_USER=n8n - POSTGRES_PASSWORD=REPLACE_WITH_STRONG_DB_PASSWORD volumes: - postgres_data:/var/lib/postgresql/data
volumes: n8n_data: postgres_data:This targets n8n 2.23.3 and PostgreSQL 18.4, the current stable releases at the time of writing; bump both to whatever is current when you deploy. Run it with docker compose up -d. A reverse proxy terminating TLS in front of this (Caddy, Traefik, or nginx) is assumed; that proxy forwards to port 5678 and sets the standard X-Forwarded-* headers.
Export from Cloud
Use the only supported path, and know its limits before you leave.
The path is Admin Panel, then Manage, then Export, which downloads your workflows in bulk to your machine.
What you do not get: no credentials, no decrypted export, no database dump, no CLI. This is the reason the self-hosted export tricks have no equivalent here.
Record before exporting, because you will not be able to read any of it later: which workflows are active, the trigger type of each, schedule timings, and a full inventory of every credential in use by name and type. That inventory is your only reference when you rebuild credentials on the new instance.
Import into self-hosted
Pick the method by scale, and respect two facts that catch people out.
UI import handles small counts: create a new workflow, open the three-dots menu, choose Import from file, and select one file at a time.
CLI import handles bulk. Use n8n import:workflow for a file or a directory of files:
docker compose exec -u node n8n n8n import:workflow --separate --input=/data/importTo switch storage engines during the move, n8n provides export:entities and import:entities, which carry data between database types (SQLite to Postgres, for example). The target database must be empty, or you force it with --truncateTables, which deletes existing data first. The exact flags are in the CLI commands reference. The related import:credentials command exists but is useless here, because you have no decrypted credential JSON from Cloud.
ID collisions are the first trap. Exports include workflow and credential IDs, and importing into a database that already holds the same IDs overwrites them. Import into a clean instance, or change the IDs before importing.
Imported is not live. A freshly imported workflow is not published; its triggers and webhooks stay dormant. Treat every imported workflow as inactive until you deliberately publish it during cutover, and verify state rather than assuming it.
| Situation | Method |
|---|---|
| A handful of workflows | UI, Import from file |
| Many workflows at once | CLI import:workflow |
| Switching SQLite to Postgres during the move | CLI export:entities then import:entities, empty target DB |
Rebuild the bindings
This is the bulk of the real work, and the part the title is about. Every external connection has to be re-established by hand on the new instance.
Credentials
Re-enter every credential manually, using the inventory you captured before leaving Cloud. There is no import path, and the key that encrypted them never left Cloud. This is tedious but mechanical; the inventory is what keeps it from becoming guesswork.
OAuth credentials
Recreating an OAuth credential on self-hosted produces a new redirect URL tied to your domain. n8n displays this OAuth Redirect URL inside the credential; copy it into each provider’s app console as an authorized redirect URI, then run the authorization flow again.
One 2.0 default is worth knowing here: n8n now requires authentication on the OAuth callback endpoint by default. The N8N_SKIP_AUTH_ON_OAUTH_CALLBACK default changed from true to false in 2.0. This is intentional and more secure; if a re-authorization fails unexpectedly, this default is a likely thing to check.
Webhooks
Production webhook URLs now use your domain through WEBHOOK_URL. Every external caller pointed at the old Cloud URL, whether that is Stripe, GitHub, Shopify, or an internal service, has to be updated to the new one, or those triggers go silent. The Webhook node exposes separate Test and Production URLs; the Production URL is the one external systems call, and the one you register.
Reconcile runtime and version differences
A fresh 2.x instance does not arrive with the conveniences Cloud configured silently. Workflows that ran fine on Cloud can fail on first run for this reason alone.
Task runners
Task runners are enabled by default in 2.0, and all Code node executions run on them. For JavaScript Code nodes, internal mode is enough: set N8N_RUNNERS_ENABLED=true, as in the compose file above, and runners execute as child processes inside the n8n container. Without runners, Code nodes fail.
Python Code node
The Pyodide-based Python node was removed in 2.0 and replaced with a native-Python implementation that runs only on task runners in external mode. External mode means a separate n8nio/runners container, because the main n8nio/n8n image no longer ships the external-mode runner. The native node also drops the old _input variable and the dot-access notation the Pyodide version supported, so existing Python Code nodes may need rewriting. If you use Python, budget time for both the infrastructure and the rewrites; the task runners docs cover the external-mode setup.
Other 2.0 defaults to check
A handful of defaults moved in 2.0 and can change behavior on a fresh instance:
- Binary data: the in-memory default for
N8N_DEFAULT_BINARY_DATA_MODEwas removed. Regular mode now defaults tofilesystem, queue mode todatabase. Make sure the host has disk for it. - The sqlite-pooled driver is now the default, which matters only if you stay on SQLite.
- Config-file permissions are enforced: n8n now requires
0600on its settings files (N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS). - ExecuteCommand and LocalFileTrigger are disabled by default, and the file-access nodes are restricted to a single directory,
~/.n8n-filesby default throughN8N_RESTRICT_FILE_ACCESS_TO. If a workflow relied on these on Cloud, re-enable or adjust them deliberately.
Schedules and timezone
Reactivate scheduled workflows and confirm GENERIC_TIMEZONE matches Cloud, or cron timing drifts relative to where it ran before.
Compatibility check
n8n ships a Migration Report at Settings, then Migration Report, available to global admins, that flags workflow-level and instance-level 2.0 issues by severity. Details in the v2.0 migration tool docs. It was built for the 1.x to 2.0 upgrade, so its main value in a Cloud move is auditing imported workflows authored under older behavior. Run it after import to surface anything the move exposed.
Parallel run and zero-downtime cutover
This is where the title’s promise is kept. Zero downtime is a sequencing problem, not a configuration one, and it only works once the new instance is built and verified.
Keep Cloud published and serving production the entire time you build and test self-hosted. Then proceed in order:
- Import and configure on self-hosted, everything unpublished.
- Re-enter credentials and re-authorize OAuth.
- Run manual and test executions against self-hosted until they pass.
- For webhook workflows, register the new URLs with providers while the Cloud endpoints still work, or stage them ready to switch.
- When verified, publish on self-hosted, flip external callers and DNS, then unpublish on Cloud.
- Keep Cloud as the rollback target until you are confident, then retire it.
The publish and unpublish buttons replaced the old active and inactive toggle in 2.0; see Understanding Workflow Publishing in n8n 2.0. Sub-workflows and error workflows that others depend on must also be published, or parent workflows pull in unpublished drafts.
Warning: Scheduled triggers fire on both instances if both are published at once. Unpublish on Cloud before publishing on self-hosted, or stagger the switch per workflow, or every scheduled job runs twice.
A checklist to run the cutover against:
- Target instance configured: encryption key, webhook URL, timezone, task runners.
- Workflows imported and unpublished.
- Credentials re-entered.
- OAuth re-authorized.
- Test executions passing.
- New webhook URLs registered with providers.
- Publish on self-hosted.
- Flip external callers and DNS.
- Unpublish on Cloud.
- Monitor, then retire Cloud.
What you now own
The real cost of leaving Cloud is operational, not migrational. The move is finite work; what follows is permanent.
Now your responsibility: encryption-key custody and backups, where losing the key loses every credential; database backups; version upgrades and breaking-change management; the reverse proxy and TLS; and, when one box is no longer enough, scaling to queue mode with Redis 8 and dedicated workers, where every instance must share the same encryption key.
Gone on Community self-hosted: external secrets managers are an Enterprise feature, and Environments needs a Business or Enterprise plan. The execution quotas and the built-in authentication Cloud handled for you no longer exist; you own auth and capacity now.
That is the honest reason to hesitate before leaving Cloud, and it has nothing to do with the migration. The one-time move is a weekend of careful work. The ongoing ownership is forever. If you want the control, the cutover above gets you there cleanly. If you mostly wanted to stop paying, price the operational time first.