n8n 3.0 Is Coming: A Production Migration Playbook for Self-Hosted Instances
Why is n8n 3.0 a migration if your workflows still look fine?
n8n has published a breaking-change guide for version 3.0, currently scheduled for October 2026. The guide says self-hosted n8n will require a Docker-based deployment, npm and npx n8n installations will no longer be supported, several legacy nodes are being removed, old Execute Workflow behavior is being removed, and the deprecated $getPairedItem expression helper is being removed. The current list is in the official n8n 3.0 breaking changes guide.
The important word is “migration.” Your automation estate is more than workflow JSON. It includes credentials encrypted with a particular key, execution history in a database, binary files in a storage backend, webhook URLs routed through a proxy, workers sharing configuration, environment variables, community nodes, custom modules, scheduled executions, waiting executions, and external systems that expect specific response timing.
A workflow can look fine in the editor while depending on any of those layers.
Consider a self-hosted instance that receives Shopify webhooks, enriches orders, writes to Postgres, and calls a shipping API. The workflow itself may use only current nodes. The instance still fails the v3 readiness test if it is launched by npm, relies on a local filesystem path that does not exist in the container, or starts without the original encryption key. The visual graph tells you almost nothing about those dependencies.
The migration risk is highest where configuration has become folklore. One person knows that the reverse proxy strips a path. Another knows that a custom Code node requires an environment variable. A third knows that worker containers must mount a certificate bundle. None of it is in version control.
I start by dividing the estate into four kinds of state.
The first is durable application state: workflows, credentials, users, projects, tags, variables, and execution metadata. This usually lives in the n8n database, though older or small installations may still rely on SQLite.
The second is secret state: N8N_ENCRYPTION_KEY, database passwords, OAuth client secrets, API keys, certificates, and any external secret-store configuration. Losing the database is bad. Losing the encryption key while keeping the database can leave credential records present but unusable.
The third is runtime state: active executions, waiting webhooks, paused approval flows, queue jobs, scheduled triggers, concurrency limits, and worker assignments. These can change while you are taking a backup.
The fourth is integration state outside n8n: webhook registrations, OAuth redirect URIs, IP allowlists, DNS records, provider retries, and downstream idempotency records. Restoring n8n does not automatically restore those relationships.
[DIAGRAM] Draw the n8n estate as four state rings around the workflow canvas: durable database state, secret state, runtime state, and external integration state. Mark which backup or runbook owns each item.
The migration is ready only when you can reconstruct all four. “The container starts” is not reconstruction.
Which n8n 3.0 changes can break a production instance?
The published v3 list is short enough to read quickly and broad enough to hide serious work.
The deployment change is direct. Self-hosted n8n must run through Docker. If you currently launch n8n through npm, npx, PM2, systemd around a global package, or a hand-built Node.js process, you need to move the runtime into a container before the major upgrade. This is not only a packaging change. Paths, user permissions, networking, certificate locations, timezone handling, process supervision, and persistent volumes all move.
The removed legacy nodes are easier to find but can carry business-critical code. n8n lists the Function node, Function Item node, and Item Lists node for removal. Function should move to the Code node in “Run Once for All Items” mode. Function Item should move to Code in “Run Once for Each Item” mode. Item Lists operations should move to the specific node that matches the intent, such as Split Out, Aggregate, Sort, Limit, Remove Duplicates, or Summarize.
A visual replacement is not enough. Legacy Function code may depend on item indexing, implicit variables, mutation patterns, or output shapes that behave differently in Code. Item Lists may be performing two conceptual operations in one node. Replacing it with a named node can expose assumptions about empty arrays, missing fields, duplicate keys, or sort types.
The Execute Workflow change is more subtle. The v3 guide says older behavior is being removed. Any estate that relies heavily on sub-workflows needs explicit tests for input mapping, waiting behavior, output item linking, error propagation, and version pinning. A parent workflow that receives a different item count can route later branches incorrectly without a hard exception.
The removed $getPairedItem helper matters when expressions rely on item lineage. n8n recommends standard item linking, such as the pairedItem property or $("<node-name>").item. If a workflow merged, split, aggregated, or reordered items, a naive expression replacement can associate a value with the wrong item.
The security-default changes deserve their own pass. The current v3 guide describes tighter handling of risky resource names and more secure credential behavior. Exact details may continue to evolve before release, so I treat the breaking-change page as a moving source, not a one-time checklist. Any migration plan written in August should be revalidated against the page before an October cutover.
Community nodes are another dependency even when they are not listed as v3 removals. A community node may import n8n internals, expect a particular runtime, depend on a Node.js package, or lag behind the major release. Inventory its package, version, source, maintainer activity, and whether a native node or HTTP Request replacement exists.
A concrete failure mode is a legacy Function node that returns one item for every order line, followed by a Merge node that expects item pairing with the original order. A direct move to Code may preserve the visible JSON and lose the pairing metadata. The totals still calculate. They calculate against the wrong order.
// Legacy-style intent, not a drop-in migration guarantee.
return items.flatMap((item, orderIndex) => {
const lines = item.json.line_items ?? [];
return lines.map((line) => ({
json: {
order_id: item.json.id,
order_index: orderIndex,
sku: line.sku,
quantity: line.quantity
},
pairedItem: {
item: orderIndex
}
}));
});The explicit pairedItem is the point. A migration test must confirm that every downstream expression still resolves the intended source item after splitting.
// [WORKFLOW JSON: sanitized workflow containing a legacy Function or Function Item node, its Code node replacement, paired-item metadata, and assertions on downstream output. This proves the migration preserves lineage, not only JSON values.]Why does changing the Docker tag and clicking test break?
Because the workflow editor tests the path you show it. Production exercises the paths you forgot.
The fastest bad migration is changing an image from a pinned 2.x version to latest, restarting the service, opening a few workflows, and clicking “Execute workflow.” That proves the editor loads and the sample path works. It does not prove scheduled triggers, production webhooks, queue workers, waiting executions, binary data, OAuth callbacks, or external retries still behave correctly.
Using latest also removes your ability to explain exactly what changed. A restart next week may pull a different build than the one you tested. Pin the version you intend to deploy. For higher-assurance environments, record the image digest as well. The release decision should be a code-reviewed change, not a side effect of restarting a container.
A container introduces filesystem boundaries. A Code node that reads /home/automation/reference.csv from the host will not see that file unless you mount it at the expected container path. A credential that references a certificate file may fail. A workflow writing temporary files may hit a read-only filesystem or a non-persistent layer. A local binary-data path may disappear when the container is replaced.
Networking changes too. From inside the container, localhost refers to the container itself. A Postgres database, Redis server, internal API, or proxy reachable as localhost from the host needs a container network name or explicit route. This one change can make credential tests fail across the instance while the n8n UI remains available.
Reverse-proxy behavior can shift when ports, base paths, or forwarded headers change. n8n needs the externally reachable webhook URL to generate correct production endpoints. OAuth providers need exact redirect URIs. A staging container on a new hostname can accidentally register callbacks that overwrite production if the provider treats the registration as a single mutable value.
The encryption key is the most dangerous “it starts” failure. n8n uses its encryption key to protect stored credentials. Every process that needs to decrypt credentials must use the same key, including main and worker instances. A newly generated key does not repair old credentials. It creates an instance that can read workflow definitions and cannot use the secrets attached to them.
A specific incident pattern is this:
The database volume is restored into a new Docker deployment.
The new instance starts with an automatically generated encryption key because the old value was never documented.
Workflows and credential names appear in the UI.
Credential-dependent nodes fail when executed.
The team starts recreating credentials manually.
Different workflows now point to a mix of old and new credential IDs.
Rollback becomes harder because the database has changed during recovery.
That is why I require a reconstruction test before a major upgrade. Build a clean machine or isolated environment from version-controlled deployment files and secret references. Restore a copy of the database. Start the exact pinned version currently used in production. Confirm credentials decrypt and synthetic workflows pass. Only then change the n8n version.
services:
n8n-main:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: unless-stopped
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_HOST}
N8N_PROTOCOL: https
WEBHOOK_URL: ${WEBHOOK_URL}
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- postgres
postgres:
image: postgres:${POSTGRES_VERSION}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
n8n_data:
postgres_data:This is a skeleton, not a production deployment. It omits TLS termination, backups, health checks, resource limits, monitoring, secret injection, and network controls. Its purpose is to make the runtime contract visible.
[WARNING] Do not copy a live production database into staging and activate workflows. Scheduled triggers and webhook registrations can send real messages, mutate real CRMs, and charge real payment methods even when the n8n hostname says “staging.”
How do you find every workflow that depends on removed behavior?
Manual clicking is not an audit.
Start with a full workflow export from the instance or project scope you are migrating. Preserve workflow IDs, names, active status, tags, project ownership, node types, node versions, credentials referenced, and sub-workflow links. Store the export in a protected migration repository. Workflow JSON can contain sensitive configuration, so access it like source code with secrets nearby, even when credential values are not embedded.
Search by node type. The v3 list gives you direct targets for Function, Function Item, and Item Lists. Search for Execute Workflow nodes and expressions containing $getPairedItem. Then search for related mechanisms that may expose the same risk: Merge, Split Out, Aggregate, Loop Over Items, item-index expressions, and nodes that reference another node's item.
A small scanner can create a first-pass report.
type N8nWorkflow = {
id?: string;
name: string;
active?: boolean;
nodes: Array<{
id: string;
name: string;
type: string;
typeVersion?: number;
parameters?: unknown;
}>;
};
const removedTypes = new Set([
"n8n-nodes-base.function",
"n8n-nodes-base.functionItem",
"n8n-nodes-base.itemLists"
]);
function scanWorkflow(workflow: N8nWorkflow) {
const serialized = JSON.stringify(workflow);
return {
workflowId: workflow.id,
workflowName: workflow.name,
active: Boolean(workflow.active),
removedNodes: workflow.nodes
.filter((node) => removedTypes.has(node.type))
.map((node) => ({
nodeId: node.id,
nodeName: node.name,
nodeType: node.type,
nodeVersion: node.typeVersion
})),
usesExecuteWorkflow: workflow.nodes.some(
(node) => node.type === "n8n-nodes-base.executeWorkflow"
),
usesGetPairedItem: serialized.includes("$getPairedItem")
};
}The scanner does not tell you how to migrate a node. It tells you where human review is required.
Next, build the sub-workflow graph. A low-visibility utility workflow may be called by dozens of active workflows. Rank it by inbound references, business criticality, and execution volume. Migrating the parent without the shared child gives you false confidence.
Then inspect Code nodes and community nodes. Search for filesystem paths, environment variables, imported packages, network hostnames, direct database connections, shell commands, and assumptions about the working directory. A Docker migration can break these even when v3 does not change the node.
Inspect trigger coverage. List active Webhook, Schedule Trigger, polling trigger, queue, form, chat, and app-trigger nodes. Record which external provider owns registration and whether activation creates or updates that registration. A clone may not receive real events until activated. Activating it may steal or duplicate them.
Inspect waiting state. Wait nodes, human approval flows, and webhook-waiting paths can remain open across a deployment. Decide whether they must survive cutover, drain before cutover, or be re-created. Do not assume a database restore into a new environment makes every external callback URL valid.
A concrete example is a parent workflow that calls a sub-workflow by ID. In staging, you import the child and it receives a new ID. The parent still references the production ID or an unavailable workflow. A manual parent test uses a branch that bypasses the call. Production orders hit the branch and fail.
The migration record should therefore include reference resolution, not just node replacement.
Workflow | Called workflows | Legacy nodes | External triggers | Credential classes | Migration owner | Test fixture |
|---|---|---|---|---|---|---|
Order intake | Normalize order, reserve stock | Function Item | Shopify webhook | Shopify, Postgres | Assigned engineer | Paid order with multiple lines |
Lead router | Resolve tenant | None | Public webhook | HighLevel, Supabase | Assigned engineer | Phone-only lead |
Invoice retry | Send invoice | Item Lists | Schedule | Stripe, email | Assigned engineer | Partial API failure |
// [WORKFLOW JSON: exported n8n workflow dependency graph with workflow IDs, Execute Workflow references, removed node types, expression hits, credentials, and trigger types. This proves the audit found shared sub-workflows and inactive dependencies.][REAL NUMBER] Insert the exact number of workflows scanned, active workflows, removed nodes, Execute Workflow references, community node packages, and unresolved sub-workflow links.
The audit is complete when every hit has one of three dispositions: migrate, prove unused and remove, or isolate on the old version temporarily with an explicit owner and end date.
How do you move an npm or PM2 install into Docker without losing the instance?
Do not combine packaging migration and major-version migration in one jump unless the estate is tiny and fully reproducible.
Move the current production version into Docker first. Keep the n8n application version unchanged. This isolates container-related problems from v3 behavior changes.
Begin by recording the current runtime. Capture the exact n8n version, Node.js version, launch command, working directory, user, environment variables, database type and version, reverse-proxy configuration, base path, webhook URL, timezone, community nodes, binary-data mode, execution-pruning settings, and any mounted files. Record where the encryption key comes from. Verify it, do not merely find a variable with the right name.
Take backups according to the database you actually use. A filesystem copy of a live SQLite file and a logical Postgres backup have different consistency properties. Use the database vendor's supported backup method. Verify the backup by restoring it into an isolated environment. A backup that has never been restored is an assertion.
Quiesce writes for the final cutover. How you do that depends on the system. You may put the webhook endpoint behind a receiver that persists events, pause scheduled workflows, stop workers after queues drain, and prevent editors from publishing. The goal is a known final state, not a hope that nothing changes during the copy.
Build the container with the same n8n version. Inject the existing encryption key. Connect it to a restored database copy. Mount the required .n8n data volume if your configuration uses files from it. Reinstall community nodes through a controlled process. Do not copy an unknown node_modules directory from the old host into the new image.
Externalize host dependencies. If Code nodes read files, create explicit mounts or move those files to object storage. If workflows call a database on localhost, replace it with a reachable service name and restrict network access. If a certificate lives in /etc/ssl/private, mount it read-only at a documented container path.
Validate credentials before activation. Run a synthetic workflow that tests each credential class with a read-only operation. A green credential test is useful, but also run the real node operation used in production because some providers expose different permissions at test and execution time.
Preserve the public webhook hostname during the packaging cutover when possible. Changing runtime and public URL together creates more variables. Route the existing hostname from the proxy to the new container after health checks pass. If the hostname must change, inventory every external registration and OAuth callback before switching.
A useful cutover sequence is:
Build and test the Docker deployment on the current n8n version.
Restore a recent database copy and verify credentials.
Run offline workflow fixtures.
Create a durable inbound-event buffer for critical webhooks.
Pause schedules and new edits.
Drain or checkpoint active executions.
Take the final database backup.
Stop the old process.
Restore or attach the final state to the container deployment.
Route the production hostname to the container.
Start with triggers controlled.
Run synthetic checks.
Resume event processing and schedules in cohorts.
Keep the old host isolated but intact until rollback risk passes.
#Record exact versions before migration.
n8n --version
node --version
npm --version
#Do not print secrets. Record only whether required variables are present.
for name in N8N_ENCRYPTION_KEY DB_TYPE WEBHOOK_URL N8N_HOST; do
if [ -n "$(printenv "$name")" ]; then
printf '%s=%s\n' "$name" "present"
else
printf '%s=%s\n' "$name" "missing"
fi
doneOnce the same n8n version runs correctly in Docker, you have reduced the v3 project to an application upgrade. That separation is one of the highest-value decisions in the whole migration.
How do you build a staging clone that cannot harm production?
A staging clone is dangerous because it contains production intent.
Importing workflows into another n8n instance copies the logic that sends emails, creates invoices, changes CRM records, updates listings, starts voice calls, and deletes data. Even if credentials are absent, somebody may attach real credentials to “test quickly.” The environment needs technical guardrails, not a warning in a README.
Start with network policy. Staging should be unable to reach production-only internal services unless a specific test requires it. Use separate database credentials, separate Supabase projects or schemas, separate queues, separate object-storage buckets, and provider sandbox accounts where available. For APIs without sandboxes, use read-only credentials or a controlled test tenant.
Change every public hostname. Production and staging must have distinct editor URLs, webhook URLs, OAuth redirect URIs, and callback paths. Put a visible environment banner in the UI through your surrounding platform or reverse proxy. A different favicon is not a security control, but visual friction helps.
Disable workflows on import. Activation should be a reviewed action. For Webhook nodes, use test URLs during fixture runs. For Schedule Trigger nodes, replace schedules with Manual Trigger or a gated webhook in the staging branch. For app triggers that register subscriptions when activated, document whether staging uses a separate provider account or does not activate at all.
Replace dangerous nodes with adapters or mocks. A staging workflow can call a local HTTP mock that records the intended email, payment, CRM write, or voice call. The mock should return realistic success and error fixtures. This gives you observable side effects without contacting the real provider.
import express from "express";
const app = express();
app.use(express.json());
app.post("/mock/highlevel/contacts", (req, res) => {
console.log(JSON.stringify({
operation: "contact.create",
request: req.body
}));
res.status(200).json({
contact: {
id: "test_contact_id",
locationId: req.body.locationId
}
});
});
app.listen(8080);Use synthetic data with shape, not production data with names removed casually. Build fixtures for the cases your workflows actually handle: missing email, international phone, multiple order lines, refunded payment, duplicate webhook, empty array, large attachment, expired OAuth token, rate-limit response, and provider timeout after a write.
Staging must preserve the same encryption-key relationship within its own processes, but it should not reuse the production encryption key unless you are restoring an encrypted database copy for a tightly controlled test. If you do restore production state, treat the environment as sensitive, block outbound traffic, and rotate or replace credentials before allowing any external calls.
The strongest pattern is a two-stage clone.
The first clone is a reconstruction environment. It uses a protected production database copy and the production encryption key solely to prove that backups and configuration can reconstruct the instance. Outbound network access is denied. This environment is short-lived.
The second clone is a functional staging environment. It has its own key, test credentials, sanitized fixtures, and controlled integrations. Workflows are imported and remapped deliberately. This is where you run migration tests repeatedly.
A concrete failure is a Retell outbound-call workflow. The engineer imports it into staging and disables the first Schedule Trigger. They miss a second webhook trigger used by a manual QA form. A tester submits the form and the workflow calls a real phone number because the Retell credential was copied. Network egress controls or a mock adapter would have stopped it.
// [WORKFLOW JSON: staging-safe clone of a production workflow where external writes are routed to mock endpoints, schedules are replaced by gated triggers, and a required environment assertion stops execution outside staging. This proves the clone cannot create real side effects.][WARNING] Redacting workflow execution history does not make a production database copy harmless. Stored credentials, OAuth tokens, webhook secrets, variables, and binary payloads may still be present.
How do you test workflow meaning instead of green node checks?
A node can be green and the workflow can be wrong.
The migration target is not “no exceptions.” It is preservation of business invariants. For each critical workflow, define the input contract, intended side effects, output contract, retry behavior, and forbidden outcomes.
Take a lead-routing workflow. Its input contract may allow email or phone, require a tenant, and accept optional campaign data. Its intended side effects may be one CRM contact upsert, one owner assignment, and one internal event. Its forbidden outcomes include cross-tenant writes, duplicate contacts from retries, erasing a populated field with an empty value, and assigning a disabled user.
Those statements become fixtures and assertions.
I test pure transformations first. Extract Code node logic into testable functions when it is complex enough to deserve tests. Feed in the same fixtures before and after migration. Compare normalized output, item count, ordering, and item pairing.
Then test workflow contracts. Trigger the workflow with a correlation ID. Capture each external call through mocks. Assert the count, order where order matters, payload, headers, and idempotency key. For sub-workflows, assert both input and returned output. For errors, assert the error workflow receives a classified event instead of a raw stack trace only.
expect(mockCalls).toEqual([
expect.objectContaining({
operation: "crm.contact.upsert",
correlationId: fixture.correlationId,
body: expect.objectContaining({
tenantId: fixture.tenantId,
email: fixture.email
})
}),
expect.objectContaining({
operation: "internal.lead.accepted",
correlationId: fixture.correlationId
})
]);Test empty and multi-item behavior. Many n8n migrations fail because the sample contains one item. Run zero items, one item, multiple items, duplicate items, and items with missing optional fields. Nodes such as Aggregate, Split Out, Merge, and Code can change item count or lineage in ways a single-item test cannot expose.
Test time. Use fixed timestamps in fixtures. Verify timezone conversion around the client's configured zone. Test schedules separately from transformations. A container may use a different system timezone from the old host, while n8n has its own timezone configuration. Never infer correct scheduling from a manual execution.
Test retries. Simulate a provider timeout before the write, a timeout after the write, a 429 with retry guidance, a 401 from an expired credential, a 403 from missing scope, and a 5xx. Your workflow should not treat them all as “retry three times.” The safe action depends on whether the remote side effect may have happened.
Test waiting executions. Create a test Wait node execution before upgrading the staging clone. Complete it after the upgrade through the expected callback. Verify the callback URL, database state, and downstream node behavior. If the workflow waits for human approval, test both approval and rejection.
Test binary data. Upload a representative file, process it, pass it through the same nodes, and confirm storage and cleanup. In queue mode, current n8n documentation says filesystem binary storage is not supported and recommends external storage such as S3 for persisted binary data. This becomes critical if the v3 migration also changes deployment architecture.
Test deactivation and reactivation. Some trigger nodes register subscriptions. Confirm whether reactivation creates duplicates, replaces the old registration, or fails because the callback URL changed. Check the provider dashboard, not only n8n.
[REAL NUMBER] Insert the exact number of critical workflows, fixtures, assertions, external side-effect mocks, waiting-execution tests, and unresolved differences. Mark coverage gaps plainly.
What changes when the same migration also introduces queue mode?
Queue mode solves a scaling problem and creates a distributed-systems problem.
n8n's current scaling documentation says queue mode provides the best scalability. The main instance receives trigger and workflow information, Redis holds the execution queue, workers perform executions, and the database stores workflow and execution data. Webhook processors can be added behind a load balancer. The official architecture and configuration are in n8n's queue mode guide.
Do not add queue mode during a major upgrade merely because you are already changing deployment files. Add it because measured workload, isolation, or availability requirements justify it. If you do both together, create a test matrix that separates v3 behavior from queue behavior.
Every main and worker process needs the same encryption key and compatible configuration. Workers need access to Postgres and Redis. Community nodes and custom files must exist in every worker image, not only the main container. A workflow that works when executed manually on the main process may fail when a worker picks it up and cannot find a package or mounted certificate.
Webhook timing changes. The request is received by a main or webhook process, the execution is queued, a worker runs it, and any Respond to Webhook result travels back. This adds broker and worker scheduling to the request path. Workflows that keep callers waiting while performing slow CRM or AI work should be redesigned to acknowledge early and process asynchronously when the provider supports it.
Binary data changes. The queue-mode guide says filesystem binary storage is not supported for persisted binary data. Use external storage where required. If one worker writes a file to its local container filesystem, another worker cannot read it later unless the storage is shared through a supported mechanism.
Concurrency changes business behavior. Adding workers does not make a non-idempotent workflow safe. It makes duplicate or overlapping executions happen faster. Two webhook deliveries for the same contact can now run on different workers at the same time. A “search then create” pattern without a database lock or provider-side uniqueness can create duplicates.
services:
n8n-main:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
command: start
environment: &n8n-environment
EXECUTIONS_MODE: queue
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
QUEUE_BULL_REDIS_HOST: redis
WEBHOOK_URL: ${WEBHOOK_URL}
n8n-worker:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
command: worker --concurrency=${N8N_WORKER_CONCURRENCY}
environment: *n8n-environment
depends_on:
- postgres
- redis
redis:
image: redis:${REDIS_VERSION}
restart: unless-stoppedAgain, this is a structural example, not a complete deployment. Pin versions, secure networks, add health checks, set resource controls, configure backups, and use a supported secret mechanism.
Monitor queue age, not only queue length. A short queue of stuck long-running jobs can be worse than a longer queue moving quickly. Track execution start delay, duration by workflow, worker concurrency, failures by worker, Redis memory, database connections, and webhook response latency.
Use separate traffic classes when necessary. A bulk backfill should not occupy every worker while live lead intake waits. n8n queue mode does not automatically understand your business priority. You may need separate instances, queues, workflow design, or an external job layer for strong priority isolation.
A concrete failure is a PDF workflow. The Webhook node receives a file, a worker writes it to local disk, a second workflow later tries to read that path, and the job lands on another worker. The path exists in the JSON. The file does not exist in that container. External object storage and passing an object key fixes the architecture.
// [WORKFLOW JSON: queue-mode-safe n8n workflow that stores incoming binary data in external object storage, passes an object key through the queue, applies an idempotency key in Postgres, and acknowledges the webhook before slow processing. This proves the workflow does not depend on worker-local state.][DIAGRAM] Draw reverse proxy, webhook processors, main process, Redis, Postgres, workers, and object storage. Trace one webhook request and one asynchronous completion event. Mark every place where a duplicate or timeout can occur.
How do you cut over and still have a credible rollback?
Rollback is not “run the old Docker Compose file.” A major upgrade may apply database migrations that the old version does not understand. Active executions may change state. Trigger registrations may move. New workflow edits may use node versions the old instance cannot run.
A credible rollback begins before upgrade.
Freeze workflow changes or put them through a branch and promotion process. Record the exact current n8n version and image. Take a verified database backup. Record persistent-volume state. Preserve the current deployment configuration and secrets. Inventory waiting executions. Create an inbound-event buffer for critical webhooks so events can be accepted while workers are paused.
Perform the upgrade first in the staging clone. Then run a canary in production. n8n is an application with shared state, so you cannot always run old and new versions against the same database safely. A canary may therefore be a subset of workflows migrated to a separate instance, a tenant cohort routed at the webhook layer, or a controlled maintenance cutover with rapid verification. Choose the pattern based on data ownership and provider registrations.
For public webhooks, I like a stable receiver in front of n8n. The receiver verifies the provider signature, stores the event in Postgres, returns the required acknowledgement, and sends a command to the active n8n environment. During migration, the receiver can pause dispatch without losing accepted events. It can also route a tenant cohort to the new instance.
create table webhook_inbox (
id uuid primary key default gen_random_uuid(),
provider text not null,
provider_event_id text,
tenant_id uuid not null,
payload_ciphertext text not null,
received_at timestamptz not null default now(),
dispatched_environment text,
dispatched_at timestamptz,
completed_at timestamptz,
last_error text,
unique (provider, tenant_id, provider_event_id)
);During cutover, stop creating new waiting state on the old instance. Drain what can finish. For waits that must survive, test their callback path explicitly. Pause schedules in a recorded order. Take the final backup. Upgrade. Run smoke tests before reactivating external triggers.
Smoke tests should cover the platform, not just one workflow: login, credential decryption, database writes, webhook routing, schedule registration, sub-workflow execution, error workflow, binary handling, queue worker execution, and one read-only call per credential class.
Define rollback triggers in advance. Examples include credential decryption failure, database migration error, unresolved trigger registration conflict, cross-tenant data risk, sustained queue growth, repeated item-linking differences, or a critical workflow producing a forbidden side effect. Do not wait for a vague sense that the release is unstable.
If rollback is required, stop new dispatch, preserve evidence, restore the pre-upgrade database and volumes into the old pinned deployment, restore proxy routing, verify credentials, then replay buffered events with idempotency. Do not point the old version at a database already migrated by v3 unless n8n explicitly documents that downgrade path.
A concrete cutover problem is a webhook provider that retries while n8n is unavailable. The provider sends the same event before and after rollback. Without a stable inbox, both instances may process it. With an inbox unique constraint on provider event ID and tenant, the second delivery resolves to the existing command.
The rollback is credible when another engineer can execute it from the runbook without recovering facts from your shell history.
What does the safer n8n 3.0 migration cost you?
It costs preparation that a simple image update does not.
You need infrastructure as code, or at least version-controlled Docker Compose and proxy configuration. You need secret management. You need a staging environment that cannot call production accidentally. You need database backups that have been restored successfully. You need workflow exports, dependency scanning, fixtures, and an owner for each critical path.
You may need to rewrite legacy nodes. Simple Function nodes can move quickly. Complex ones may deserve extraction into a tested service or package. Item Lists replacements can turn one node into several clearer nodes. That increases canvas size and test surface even when it improves maintainability.
You may need to change where state lives. Host files move to object storage. Shared variables move to Postgres. OAuth refresh logic moves out of copied Code nodes. Webhook acceptance moves in front of n8n. Each change adds a component and removes an implicit dependency.
Queue mode adds Redis, worker management, distributed logs, and more failure modes. For a small internal instance, this may be unnecessary. A single Dockerized n8n process with Postgres, good backups, strict concurrency, and well-designed workflows can be the better system. Scale because of measured need, not architecture fashion.
There is also a temporary productivity cost. A change freeze slows feature work. Test fixtures take time. Running old and new pathways in controlled cohorts creates operational overhead. The return is not a flashy feature. It is knowing what will happen when the next provider webhook arrives during a restart.
I would avoid Kubernetes for a first container migration unless the organization already operates it well. Kubernetes can solve scheduling and availability problems. It can also bury a straightforward n8n migration under ingress controllers, secrets, persistent volumes, probes, autoscaling, and cluster policy. Docker Compose or a managed container platform is often the clearer first destination. The right choice depends on the client's existing operations team, not on n8n alone.
I would also avoid rebuilding every workflow “cleanly” during the version migration. That combines behavior change, architecture change, and platform change. Migrate only what v3 or Docker requires. Log refactoring candidates. Improve them after the new baseline is stable, unless the old design prevents safe migration.
Migration choice | Lower immediate cost | Lower operating risk |
|---|---|---|
Change image in place | Yes | No |
Move current version to Docker first | No | Yes |
Reuse production credentials in staging | Yes | No |
Build mocks and test credentials | No | Yes |
Add queue mode during v3 upgrade | Sometimes | Only when separately tested |
Keep shared host files | Yes | No in multi-worker setups |
Pin image versions | Slightly more work | Yes |
[REAL NUMBER] Insert actual migration effort by inventory, Docker conversion, workflow remediation, staging, testing, cutover, and monitoring. Add ongoing infrastructure cost only after it is measured. Mark forecasts as unverified.
The safer path costs visible engineering time. The unsafe path hides the cost until the maintenance window becomes an incident.
What would I do differently before the next major n8n upgrade?
I would make the instance disposable earlier.
Disposable does not mean stateless. It means the runtime can be rebuilt from declared configuration while durable state comes from known stores. The container image, environment contract, proxy routes, community-node packages, mounted certificates, and worker commands should be reproducible. The database, object storage, and secret store should be backed up and tested separately.
I would pin every n8n production version and schedule smaller updates. n8n recommends updating frequently and testing updates in a separate environment. Smaller version jumps reduce the number of changes you investigate at once. The official guidance is in n8n's update documentation.
I would keep a machine-generated workflow inventory. Every deployment should know which node types, node versions, community packages, credentials, triggers, and sub-workflows are in use. When a breaking-change notice names a node, the impact report should take minutes, not days.
I would treat Code nodes as code. Complex logic should live in version control with tests, even if n8n remains the orchestration surface. A tiny transformation can stay inline. Tax calculation, inventory allocation, tenant resolution, signature verification, and retry classification deserve typed functions and review.
I would put a stable webhook inbox in front of critical workflows earlier. It decouples provider acknowledgement from n8n availability, gives you replay, and makes migrations less dependent on the exact maintenance window. It also makes duplicate handling explicit.
I would create test fixtures while incidents are fresh. When a workflow fails on a phone-only contact, an empty Shopify line item, a malformed Retell function call, or a HighLevel 429, save a sanitized fixture and expected outcome. The regression suite becomes a record of real failure modes.
I would measure workflow criticality and recovery objectives. Not every workflow needs the same migration ceremony. A nightly internal report can tolerate manual replay. A payment or lead webhook may require a durable inbox and fast recovery. Without that classification, teams either overbuild everything or underprotect the important paths.
I would document ownership at the workflow and platform level. The person who understands the business flow may not own Postgres backups. The person who runs Docker may not know which external trigger registrations are sensitive. A migration runbook needs both.
A concrete improvement is a pre-upgrade pipeline. It exports workflows, scans for deprecated nodes and expressions, validates the Compose file, confirms required secret references exist, restores the latest backup into an isolated environment, starts the pinned current version, runs smoke tests, upgrades the clone, then runs contract tests. The pipeline does not replace human review. It makes routine proof repeatable.
// [WORKFLOW JSON: maintenance workflow that records the current n8n version, exports workflow metadata, scans for deprecated nodes, checks backup freshness, triggers staging smoke tests, and writes a signed migration report. This proves upgrade readiness is an operating process rather than a one-off spreadsheet.][DIAGRAM] Draw the recurring upgrade pipeline from release notice to impact scan, staging restore, current-version reconstruction, target-version upgrade, contract tests, approval, canary, and production rollout.
The biggest thing I would change is timing. I would not wait for the major release to start reconstructing the current instance. A team that cannot rebuild 2.x safely is not ready to upgrade it.
What is still hard after every workflow passes?
The hardest part is the state between workflows.
Your fixtures can prove a lead workflow returns the right JSON. They cannot automatically prove that a provider will retry during the cutover in the same way it did in staging. They cannot prove an OAuth provider will preserve every registration when you deactivate and reactivate a trigger. They cannot prove a waiting execution created weeks earlier will receive its callback after the public route moves. They cannot prove an external API did not complete a write just before your worker lost the response.
This is the loop from the opening. The scary part was never the legacy Function node by itself. It was the undocumented relationship between the process manager, encryption key, database, hidden sub-workflow, and live order traffic.
After every workflow passes, I still ask five questions. Where can an event exist while n8n is down? How is a duplicate recognized across a restore? Which state cannot be rebuilt from the database? Which external registrations change when a workflow activates? Which in-flight operations may have completed remotely without a local success record?
Those questions do not have one n8n setting. They require architecture around n8n.
I will look at a self-hosted deployment file or workflow inventory and point out which parts are likely to make a v3 cutover irreversible.
Most teams test whether the new instance can run. The unsolved part is proving the old and new instances cannot both act on the same unfinished business event.