Self-Hosting n8n for Engineering Teams, A Pragmatic Setup Guide
TL;DR — Run n8n self-hosted with Docker Compose, Postgres, and queue mode if you have more than a couple of engineers hitting it. / SQLite is fine for a laptop demo, miserable in production. / Webhook scaling is the single biggest reason teams outgrow n8n Cloud and the single biggest reason their self-hosted setup falls over.
I’ve spent the last few months migrating a thirty-person engineering org from a tangled mess of cron jobs, Lambda functions, and one very tired Zapier account onto a self-hosted n8n instance. The promise was simple: one place where PMOs, support, and engineers could wire APIs together without rewriting Python every time someone wanted a Slack notification on a Jira transition. The reality, as always, was a bit messier.
What follows is the setup I wish someone had handed me on day one. It assumes you’re comfortable with Docker, you have a Postgres instance you trust, and you understand why running automations on the same node as your production API is a bad idea. If you’re evaluating n8n against Zapier or Power Automate for an engineering team, the short answer is that n8n wins on cost and extensibility once you cross roughly 5,000 task executions a month — but only if you’re willing to operate it yourself.
This post focuses on the boring infrastructure layer. The fun stuff — actually wiring Jira, Linear, and GitHub together — is what the rest of this month’s posts are for.
Why self-host instead of using n8n Cloud
The marketing pitch for n8n Cloud is real. Zero ops, hosted webhooks, a usable free tier. For a solo engineer or a team of five, I’d recommend it without hesitation. The break-even where self-hosting starts to make sense is somewhere around the moment one of these becomes true:
- You need to call internal APIs that don’t have a public ingress.
- Your compliance team has opinions about where data lives.
- You’re approaching the execution limits on the Pro tier and the next jump is painful.
- You want to write custom nodes in TypeScript and ship them through your normal CI pipeline.
For the team I was working with, all four were true within about six weeks of pilot. We migrated.
The other thing to be clear-eyed about: self-hosting n8n isn’t free. You’re trading a subscription fee for an on-call rotation. Budget for it.
The baseline Docker Compose setup
Here’s the docker-compose.yml I landed on after three iterations. It’s deliberately boring. Postgres for the database, Redis for queue mode, and two n8n processes — one main, one worker.
version: "3.8"
services:
postgres:
image: postgres:15.3-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: n8n
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7.0-alpine
restart: unless-stopped
volumes:
- redis_data:/data
n8n-main:
image: n8nio/n8n:0.225.2
restart: unless-stopped
ports:
- "5678:5678"
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_HOST}
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_HOST}/
GENERIC_TIMEZONE: Asia/Jakarta
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
- n8n_data:/home/node/.n8n
n8n-worker:
image: n8nio/n8n:0.225.2
restart: unless-stopped
command: worker
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
depends_on:
- n8n-main
volumes:
postgres_data:
redis_data:
n8n_data:
A few non-obvious things. The N8N_ENCRYPTION_KEY encrypts stored credentials at rest in Postgres. Rotate it and every credential breaks, so back it up the way you’d back up a database password — somewhere your bus factor of one can’t accidentally delete. The WEBHOOK_URL has to be your externally reachable HTTPS URL, not the internal Docker hostname, or webhooks generated inside workflows will point at localhost.
Queue mode is the single setting most new self-hosters skip. By default n8n runs everything in-process on the main node, which means a long-running workflow blocks the editor UI and webhook ingestion. Once you turn on EXECUTIONS_MODE=queue, the main process becomes a coordinator and workers pick jobs off Redis. You can scale workers horizontally by adding more n8n-worker replicas behind the same Redis instance.
Hardening for actual production use
The Compose file above gets you running. It does not get you ready for traffic. Here’s what I added before pointing real workflows at it:
TLS and reverse proxy
I’m using Caddy 2.6 in front of n8n. It handles automatic Let’s Encrypt and forwards the right headers so OAuth callbacks work. The minimal Caddyfile:
n8n.example.com {
reverse_proxy n8n-main:5678
header X-Frame-Options "SAMEORIGIN"
header Strict-Transport-Security "max-age=31536000;"
}
Traefik works equally well. The point is: do not expose port 5678 directly. n8n’s auth is fine, but you want a layer in front for rate limiting and TLS termination.
Authentication
For a team larger than four or five people, the built-in basic auth gets old fast. n8n’s owner-account model and user management (introduced in late 2022) is what you want. If you’re on the Enterprise tier, SAML is available; otherwise SSO requires a workaround through your reverse proxy. The official n8n hosting docs cover the user-management setup in detail.
Backups
Two things to back up: the Postgres database (everything except encrypted credentials and workflows lives here) and the N8N_ENCRYPTION_KEY. Without the key, a database restore is useless for decrypting stored credentials. I use pg_dump on a nightly cron, ship it to S3, and store the encryption key in 1Password with break-glass access.
Webhook scaling, the part nobody mentions
The most common production failure I’ve seen with self-hosted n8n is webhook saturation. By default, webhooks are handled by the main process — the same one serving the editor UI. Under load, the UI gets slow, then unresponsive, then the webhook starts timing out and your upstream system (GitHub, Jira, Slack) starts retrying.
The fix is to run dedicated webhook processes:
n8n-webhook:
image: n8nio/n8n:0.225.2
restart: unless-stopped
command: webhook
environment:
EXECUTIONS_MODE: queue
DB_TYPE: postgresdb
# ... same DB and Redis config as main
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
deploy:
replicas: 2
Then point your reverse proxy at the webhook service for /webhook/* paths and at the main service for everything else. This single change took our P99 webhook latency from 2.4 seconds (with periodic 30-second spikes) down to a stable 180ms.
Common Pitfalls
A few traps I or someone on my team hit:
- SQLite default. If you don’t set
DB_TYPE=postgresdb, n8n silently uses SQLite. It’ll work fine until you try to scale workers, at which point you’ll have a wonderful afternoon debugging file-lock contention. - Timezone drift.
GENERIC_TIMEZONEonly affects the schedule trigger node. Cron expressions in other places interpret UTC. Don’t ask how I learned this. - Credential leakage in exports. When you export a workflow as JSON to commit to git, credentials are stripped — but the credential names and IDs are exposed. Don’t name a credential
prod-stripe-key-real. Future you will be embarrassed. - Memory limits. The default Node heap is 4 GB on the n8n image. Large CSV processing or wide Postgres queries can OOM the worker. Set
NODE_OPTIONS=--max-old-space-size=8192if you’re processing real data volumes. - The
Functionnode and npm packages. By default, theFunctionnode has no access to external npm modules. You can allowlist them withNODE_FUNCTION_ALLOW_EXTERNAL=lodash,axios, but this is also a security boundary. Be deliberate.
Wrapping Up
n8n self-hosted is one of those tools that rewards being a bit boring about the infrastructure. Postgres, Redis, queue mode, dedicated webhook workers, and a reverse proxy in front. None of it is novel, all of it matters. Once you’ve got a stable platform, the interesting work starts: actually automating the workflows your team is currently doing by hand.
Next post in this series digs into the first big one — driving Jira from scripts with the REST v3 API in a way that doesn’t make you want to throw the API spec into the sea.