n8n Self-Hosting Guide: Docker, Kubernetes, and Bare Metal in Production
I have been running n8n self-hosted since 2022 across three different topologies: a single-VPS Docker Compose setup, a small Kubernetes cluster with queue mode, and a bare systemd install on a hardened Debian box. Each one earns its place, and picking wrong costs you weekends. This n8n self-hosting guide is the version I wish I had when I started, written for teams that want production stability, not a demo.
The short verdict up front: run Docker Compose until you physically cannot. Move to Kubernetes only when you already run Kubernetes for other services, or when you are genuinely north of 50,000 executions per day. The bare systemd path exists for people like me who enjoy minimal stacks and want to understand every moving part. All three paths work. The wrong one for your situation will feel like a second job.
If you are still choosing between platforms, read Make.com vs n8n for production workloads first. If you are migrating from Zapier, migrate Zapier to n8n covers the mechanics. If you are German-speaking, the DACH-focused n8n selbst hosten Anleitung is the sister post to this one.
Why self-host n8n
n8n Cloud is fine. For many teams the math does not work past a certain volume, and self-hosting brings control back. My reasons, in rough order of how often they decide the call:
Data sovereignty and compliance. If your workflows touch PII, financial records, or health data, shipping every execution payload to a SaaS multi-tenant stack widens your SOC2 audit scope unnecessarily. GDPR is not abstract: you need a data processing agreement for every external processor in the chain, and self-hosting collapses that list. Same logic applies to HIPAA and PCI environments.
Cost at volume. n8n Cloud pricing crosses the self-host ROI line somewhere between 10,000 and 20,000 executions per month depending on the plan. A Hetzner CX32 VPS at roughly eight dollars a month will happily run 100,000 executions if your workflows are not CPU-heavy. That gap only widens as you grow.
Custom nodes without limits. Self-hosted n8n lets you drop community nodes and your own TypeScript nodes into the data directory without asking permission. No allow-list gatekeeping.
Air-gapped and VPN-only integrations. Many internal systems are only reachable from inside a corporate network. A self-hosted n8n instance living on the same private subnet calls them directly. Cloud cannot.
Versioning workflows in Git. Self-hosted n8n can export workflows to JSON and sync them into a repo, giving you real code review, PR history, and rollback. This is how serious teams run automation.
Decision: Docker Compose vs Kubernetes vs bare
Before any installation commands, pick the right path.
| Criterion | Docker Compose | Kubernetes | Bare systemd |
|---|---|---|---|
| Setup time | 30 minutes | 1-2 days | 2 hours |
| Ops overhead | Low | Medium-high | Medium |
| Horizontal scale | No (queue mode vertical) | Yes, via HPA | No |
| Good up to | ~50k executions/day | Unlimited | ~20k executions/day |
| Best for | 95% of self-hosters | Existing k8s shops | Minimalists, tight boxes |
| Backup complexity | Simple | Complex (PVCs, secrets) | Simple |
Pick Docker Compose if you are a solo engineer, a small team, or a consultancy running workflows for clients. One host, one docker-compose.yml, boring and reliable.
Pick Kubernetes if you already operate a cluster for other services. Do not stand up Kubernetes just for n8n. The operational overhead is not worth it below roughly 50,000 executions per day.
Pick bare systemd + node if you want the absolute minimum moving parts, if you already run a hardened Debian or Ubuntu box for other services, or if you are learning and want to see every layer. I run this on my personal VPS for the same reasons I prefer systemd services for AI servers.
Path 1: Docker Compose production setup
This is the boring answer, which makes it the right one for most readers. I have run this layout unchanged for eighteen months.
Directory layout
sudo mkdir -p /opt/n8n/{data,db,files,caddy}
sudo chown -R 1000:1000 /opt/n8n/data /opt/n8n/files
cd /opt/n8n
The data directory holds n8n’s sqlite-free workflow data, installed community nodes, and credentials. The db directory is the Postgres volume. files is for binary data that workflows read or write. caddy holds the reverse proxy config and TLS certs.
The docker-compose.yml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
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
N8N_PORT: 5678
WEBHOOK_URL: https://${N8N_HOST}/
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}
EXECUTIONS_DATA_SAVE_ON_SUCCESS: none
EXECUTIONS_DATA_MAX_AGE: 720
N8N_LOG_LEVEL: info
N8N_METRICS: "true"
volumes:
- ./data:/home/node/.n8n
- ./files:/files
ports:
- "127.0.0.1:5678:5678"
worker:
image: n8nio/n8n:latest
restart: unless-stopped
command: worker
depends_on:
- n8n
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
EXECUTIONS_MODE: queue
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- ./data:/home/node/.n8n
- ./files:/files
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
depends_on:
- n8n
The .env file
# /opt/n8n/.env
POSTGRES_USER=n8n
POSTGRES_PASSWORD=$(openssl rand -hex 24)
POSTGRES_DB=n8n
REDIS_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
N8N_HOST=n8n.example.com
GENERIC_TIMEZONE=Europe/Madrid
Generate real values with openssl rand. The N8N_ENCRYPTION_KEY is the one secret that, if lost, makes every stored credential permanently unrecoverable. Back it up in a password manager the same day you generate it.
The Caddyfile
n8n.example.com {
reverse_proxy n8n:5678
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
}
Bring it up
docker compose pull
docker compose up -d
docker compose logs -f n8n
Watch for the Editor is now accessible via line. Visit the host, create the owner account, and you are running production n8n.
Path 2: Kubernetes deployment
Only take this path if you already operate a cluster. The minimum viable production layout needs a namespace, a Secret for the encryption key, a ConfigMap for non-secret env, a Deployment for the main n8n process, a Deployment for workers, a StatefulSet or external managed Postgres, Redis, and an Ingress with cert-manager for TLS.
Namespace and secrets
kubectl create namespace n8n
kubectl -n n8n create secret generic n8n-secrets \
--from-literal=N8N_ENCRYPTION_KEY=$(openssl rand -hex 32) \
--from-literal=DB_POSTGRESDB_PASSWORD=$(openssl rand -hex 24) \
--from-literal=REDIS_PASSWORD=$(openssl rand -hex 24)
n8n Deployment (main)
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n
namespace: n8n
spec:
replicas: 1
selector:
matchLabels:
app: n8n
template:
metadata:
labels:
app: n8n
spec:
containers:
- name: n8n
image: n8nio/n8n:latest
ports:
- containerPort: 5678
envFrom:
- configMapRef:
name: n8n-config
- secretRef:
name: n8n-secrets
volumeMounts:
- name: data
mountPath: /home/node/.n8n
readinessProbe:
httpGet:
path: /healthz
port: 5678
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5678
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: 2, memory: 2Gi }
volumes:
- name: data
persistentVolumeClaim:
claimName: n8n-data
The main pod handles UI, API, and webhook traffic. Keep it at replicas: 1. Only workers scale out.
Worker Deployment with HPA
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n-worker
namespace: n8n
spec:
replicas: 2
selector:
matchLabels:
app: n8n-worker
template:
metadata:
labels:
app: n8n-worker
spec:
containers:
- name: worker
image: n8nio/n8n:latest
args: ["worker"]
envFrom:
- configMapRef:
name: n8n-config
- secretRef:
name: n8n-secrets
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: 2, memory: 2Gi }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: n8n-worker
namespace: n8n
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: n8n-worker
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
Database and Redis
For Postgres, use a managed service if you can. RDS, Cloud SQL, and Hetzner Managed Postgres all work. The alternative is a Postgres StatefulSet with a PVC, which is fine but gives you one more thing to back up yourself. Redis can run as a simple Deployment with a ClusterIP service, since queue state is recoverable from the database if Redis is ever lost.
Ingress with cert-manager
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: n8n
namespace: n8n
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 64m
spec:
ingressClassName: nginx
tls:
- hosts: [n8n.example.com]
secretName: n8n-tls
rules:
- host: n8n.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: n8n, port: { number: 5678 } }
Helm alternative
The community Helm chart at 8gears/n8n is fine if you do not want to hand-write manifests. I prefer explicit YAML because I can read it in six months and know what it does. Your call.
Path 3: bare systemd + node
Minimal stack. No Docker, no Kubernetes. Just node, Postgres, nginx.
Install
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs postgresql nginx certbot python3-certbot-nginx
sudo useradd -m -s /bin/bash n8n
sudo -u n8n npm install -g n8n
Postgres
sudo -u postgres createuser --pwprompt n8n
sudo -u postgres createdb -O n8n n8n
systemd unit
# /etc/systemd/system/n8n.service
[Unit]
Description=n8n workflow automation
After=network.target postgresql.service
[Service]
Type=simple
User=n8n
WorkingDirectory=/home/n8n
Environment="DB_TYPE=postgresdb"
Environment="DB_POSTGRESDB_HOST=127.0.0.1"
Environment="DB_POSTGRESDB_DATABASE=n8n"
Environment="DB_POSTGRESDB_USER=n8n"
Environment="DB_POSTGRESDB_PASSWORD=REPLACE_ME"
Environment="N8N_ENCRYPTION_KEY=REPLACE_ME"
Environment="N8N_HOST=n8n.example.com"
Environment="N8N_PROTOCOL=https"
Environment="WEBHOOK_URL=https://n8n.example.com/"
Environment="GENERIC_TIMEZONE=Europe/Madrid"
ExecStart=/usr/bin/n8n start
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now n8n
sudo journalctl -u n8n -f
The same hardening patterns I use elsewhere apply: ProtectSystem=strict, NoNewPrivileges=true, and a dedicated data directory under /home/n8n/.n8n. See systemd services for AI servers for the full hardening template, and Linux VPS for AI development for the base image I start from.
nginx + certbot
server {
listen 443 ssl http2;
server_name n8n.example.com;
ssl_certificate /etc/letsencrypt/live/n8n.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.example.com/privkey.pem;
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Run sudo certbot --nginx -d n8n.example.com once and cron handles renewal.
Production configuration that matters
These settings apply to all three paths. The defaults are tuned for dev, not prod.
EXECUTIONS_MODE=queuesplits execution from the main process. Required for any serious load.EXECUTIONS_DATA_SAVE_ON_SUCCESS=nonestops n8n from writing a row to the executions table for every successful run. On a busy instance this alone keeps Postgres from ballooning. Keep error records.EXECUTIONS_DATA_MAX_AGE=720retains execution history for 30 days (value is in hours). Tune to your audit needs.GENERIC_TIMEZONE=Europe/Madridor wherever you are. Cron triggers respect this.N8N_BASIC_AUTH_ACTIVE=truewith a password at minimum. Never run n8n public without auth. For real teams put Authelia or Authentik in front as forward auth.N8N_LOG_LEVEL=infoin production.debugfloods the journal.N8N_METRICS=trueexposes/metricsfor Prometheus.
Backups you can actually restore
Backup plans no one has ever tested are fiction. Mine is a nightly systemd timer that does three things:
#!/usr/bin/env bash
# /usr/local/bin/n8n-backup.sh
set -euo pipefail
TS=$(date -u +%Y%m%dT%H%M%SZ)
DEST=/var/backups/n8n
mkdir -p "$DEST"
docker compose -f /opt/n8n/docker-compose.yml exec -T postgres \
pg_dump -U n8n n8n | gzip > "$DEST/db-$TS.sql.gz"
tar czf "$DEST/data-$TS.tar.gz" -C /opt/n8n data files
cp /opt/n8n/.env "$DEST/env-$TS.bak"
restic -r "$RESTIC_REPOSITORY" backup "$DEST"
find "$DEST" -type f -mtime +7 -delete
Three things get backed up: the Postgres dump, the data and files directories (custom nodes and binary payloads), and the .env containing the encryption key. Push them off-box with restic or borg. The encryption key lives in a password manager too, because if you lose it, every stored credential is dead weight.
Then the part that matters: once a year I restore the backup onto a fresh VPS and try to log in. If it does not work, the plan does not exist. Schedule it.
Security
n8n is an authenticated web app with root-level access to everything you connect it to. Treat it that way.
- Never expose the UI without auth. Basic auth is a minimum, SSO forward auth via Authelia or Authentik is better.
- Webhook URLs are the attack surface. Webhooks are public by design. Validate every input inside the workflow. Rate limit at the reverse proxy. Use the n8n webhook auth header option for machine-to-machine webhooks.
ufwdefault deny inbound, allow 22, 80, 443, nothing else. Postgres and Redis stay on the Docker network or localhost.fail2banon the reverse proxy watching for 401 floods against the login page.- Weekly image updates. I run a cron that pulls
n8nio/n8n:latestand restarts the stack every Sunday. Watchtower does the same thing if you prefer that flavor. - Rotating
N8N_ENCRYPTION_KEYis not a casual operation. It requires re-encrypting every credential in the database, which n8n supports with a CLI command, but you must have the old key available. Plan it, do not panic-rotate.
Scaling n8n: the real numbers
From what I have measured across my own instances and client deployments:
- Single instance, no queue mode: 10-20 concurrent executions before the UI starts stuttering. Fine for a team of five running a few dozen workflows.
- Queue mode, one host, 2-4 workers: 100-500 concurrent executions, 50,000 per day comfortably. This is where most self-hosters top out, and most never need more.
- Kubernetes with HPA on workers: the bottleneck moves to Postgres. Connection pool exhaustion is the first symptom. Move to pgbouncer or a managed Postgres with a higher connection limit.
- Multi-region or multi-cluster: n8n was not designed for active-active. You can run it, but you will own every edge case. Beyond this point a dedicated orchestration platform is usually the right call.
When Docker Compose is not enough
Concrete triggers that actually justify the move to Kubernetes:
- More than 100,000 executions per day and Postgres is your bottleneck even after pgbouncer.
- Zero-downtime upgrades are contractually required. Blue-green is much cleaner on Kubernetes.
- You have an in-house platform team that runs every service on Kubernetes and wants consistency.
- You need multi-region failover and you have an active-active Postgres.
If none of those apply, stay on Docker Compose.
AI workflows on self-hosted n8n
Self-hosted n8n runs the same LangChain nodes and AI nodes as Cloud. I call Claude via the HTTP Request node for most agent work, because it is simpler to reason about than the abstracted LangChain wrappers. The pattern looks like this:
- Trigger (webhook or schedule).
- Data prep in a Code node.
- HTTP Request node to
https://api.anthropic.com/v1/messageswith the API key in credentials and prompt caching on the system block. The patterns in Claude API prompt caching translate directly. - Parse response with Code node or a tool-use structured output pattern. See Claude API structured output for the prefill approach I use.
- Side effects: write to DB, post to Slack, update a CRM.
For heavier agent logic, a Code node with a short TypeScript function calling the Anthropic SDK beats trying to wire complex state through nodes. If you want real MCP server access from n8n, the HTTP bridge pattern in build an MCP server in TypeScript works well.
Monitoring
Enable N8N_METRICS=true and point Prometheus at /metrics. The four dashboards I actually look at:
- Execution rate and error rate by workflow.
- Execution duration p95 and p99. Anything trending up means a third-party API is degrading or a workflow regressed.
- Queue depth in Redis. If it is climbing and not draining, workers are stuck.
- Postgres connection pool usage and slow query log.
Alerts I wire up: error rate above 2 percent for 5 minutes, queue depth above 100 for 10 minutes, worker pod unhealthy, Postgres connections above 80 percent. Everything else is noise.
Upgrade path without outages
n8n ships major versions with breaking changes, and the release notes are the only source of truth. My process:
- Staging instance with a copy of production data. Pull the new image, run, click through the critical workflows.
- Pin image tags in production to specific versions, not
latest, once you are past the experimentation phase.n8nio/n8n:1.78.2beatslatestfor reproducibility. - On Docker Compose: pull new image,
docker compose up -d, watch logs. One minute of downtime. - On Kubernetes: blue-green with two Deployments and a service switch, or a rolling update if your workflows tolerate it.
- Back up before every major upgrade. If the migration fails, restore the database and roll back the image.
That is the whole playbook. n8n self-hosting is boring once it is set up, which is the point. The setup pays for itself in the first month you do not ship a credential to a third-party SaaS or explain execution costs to your CFO.