
Self-host n8n in production: Docker Compose, PostgreSQL, HTTPS, and queue mode
npx n8n or a single container is fine to try, but production needs Docker Compose with PostgreSQL (SQLite locks and blocks queue mode), a persistent N8N_ENCRYPTION_KEY you back up (it encrypts every credential), and a reverse proxy for HTTPS with WEBHOOK_URL set to your real domain. Add Redis queue mode when one instance stops keeping up.
Can you run n8n in production with npx n8n?
You can, but you shouldn’t. npx n8n or a single Docker container is perfectly fine to try n8n on your laptop for an afternoon. A real production install needs four things the defaults don’t give you: Docker Compose to run n8n next to a proper database, PostgreSQL (the default SQLite locks under load and can’t do queue mode), a persistent N8N_ENCRYPTION_KEY that you generate and back up (it encrypts every credential you store), and a reverse proxy for HTTPS with WEBHOOK_URL pointed at your real domain. Once one instance stops keeping up, you add Redis queue mode. If you’re still weighing whether n8n is even the right layer, AI agents vs automation tools compares n8n, Zapier, and Make against true agents. This guide walks the whole install, in the order I set it up on my own server.
Why n8n’s defaults break in production
The out-of-the-box experience is optimized for “does it run at all,” not “does it survive real traffic.” Three defaults will bite you.
- SQLite. n8n ships with SQLite so a first run needs zero setup. But SQLite is a single file with coarse locking. Under concurrent executions you get slow editor performance and
database is lockederrors, and — this is the dealbreaker — SQLite does not support queue mode, so you can never scale horizontally. - No TLS. The editor listens on plain HTTP on port
5678. Sending your credentials and workflow data across the internet in the clear is not an option. - An ephemeral encryption key. If you don’t set
N8N_ENCRYPTION_KEY, n8n generates one and writes it into the data directory. If that directory isn’t persisted — or you rebuild the container elsewhere — the new key can’t decrypt your stored credentials, and every connection silently breaks.
Fix these four and n8n goes from “demo that runs” to “service you can trust.” Let’s build it.
A docker-compose.yml with n8n + PostgreSQL
Docker Compose is the recommended install method for most self-hosters: it isolates n8n and its database as separate containers and turns upgrades into a one-command operation. Here is a minimal-but-production-shaped docker-compose.yml that runs n8n against PostgreSQL. It reads secrets from an .env file in the same directory (covered next).
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_POSTGRESDB_DATABASE}
- POSTGRES_USER=${DB_POSTGRESDB_USER}
- POSTGRES_PASSWORD=${DB_POSTGRESDB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_POSTGRESDB_USER}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
environment:
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${DB_POSTGRESDB_DATABASE}
- DB_POSTGRESDB_USER=${DB_POSTGRESDB_USER}
- DB_POSTGRESDB_PASSWORD=${DB_POSTGRESDB_PASSWORD}
- N8N_HOST=${N8N_HOST}
- N8N_PROTOCOL=https
- N8N_PORT=5678
- WEBHOOK_URL=${WEBHOOK_URL}
- N8N_EDITOR_BASE_URL=${WEBHOOK_URL}
- GENERIC_TIMEZONE=America/Mexico_City
- TZ=America/Mexico_City
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
Note ports: "127.0.0.1:5678:5678" — n8n only binds to localhost, so the port is never reachable from the internet directly. The proxy (later) is the only public door.
N8N_ENCRYPTION_KEY: generate it, set it, and back it up
This is the single most important value in your whole install. n8n uses N8N_ENCRYPTION_KEY to encrypt every credential — API keys, OAuth tokens, passwords — before it writes them to the database. Lose the key and those credentials are unrecoverable: the ciphertext stays in Postgres, but nothing can decrypt it.
Generate one and set it explicitly. Do not let n8n auto-generate a key you can’t reproduce.
openssl rand -hex 32
Put it — and your database secrets — in an .env file next to docker-compose.yml:
# .env (chmod 600, never commit this)
N8N_ENCRYPTION_KEY=paste-the-openssl-rand-hex-32-output-here
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=use-a-long-random-password-here
N8N_HOST=n8n.example.com
WEBHOOK_URL=https://n8n.example.com/
Then copy that key somewhere that survives the server dying — a password manager or a secrets vault, not just the disk it lives on. I keep mine in the same place as the rest of my infrastructure secrets. This matters for backups too, which is why a database dump alone is worthless: without the key it’s a pile of encrypted blobs.
- Generateopenssl rand -hex 32 produces a 64-char hex string
- SetN8N_ENCRYPTION_KEY in .env, injected into the container
- Back up off-boxpassword manager or vault, not just the server disk
- Never rotate blindchanging the key orphans every existing credential
Point n8n at PostgreSQL: DB_TYPE and the DB_POSTGRESDB_* vars
The database switch is entirely env-driven. Setting DB_TYPE=postgresdb tells n8n to stop using SQLite; the DB_POSTGRESDB_* vars tell it where Postgres lives. In the Compose file above these are already wired — the important detail is DB_POSTGRESDB_HOST=postgres, which is the service name, resolved over Docker’s internal network:
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres # the compose service name
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=... # from your .env
On first boot with an empty database, n8n runs its own migrations and creates the schema — you don’t run any SQL yourself. If you’re moving off an existing SQLite install, don’t try to hand-migrate the file; export your workflows and credentials from the running instance and re-import against the new Postgres-backed one.
Host, protocol, and WEBHOOK_URL: the vars that break webhooks if wrong
n8n runs behind a proxy, so it can’t reliably guess its own public URL. You have to tell it. Four vars do that job, and WEBHOOK_URL is the one people get wrong.
N8N_HOST— your public domain, e.g.n8n.example.com.N8N_PROTOCOL=https— so generated URLs usehttps.N8N_PORT— the internal port n8n listens on (5678); the public port is handled by the proxy.WEBHOOK_URL— the public HTTPS base used to build every webhook URL n8n hands out to external services.N8N_EDITOR_BASE_URL— the public URL of the editor UI itself.
If WEBHOOK_URL is wrong or unset, n8n builds webhook URLs from its internal hostname — so a Trigger node shows a URL like http://localhost:5678/webhook/..., you paste that into Stripe or GitHub, and the callback never arrives because the outside world can’t reach localhost. Set it to your real domain, with the scheme and a trailing slash:
N8N_HOST=n8n.example.com
N8N_PROTOCOL=https
N8N_PORT=5678
WEBHOOK_URL=https://n8n.example.com/
N8N_EDITOR_BASE_URL=https://n8n.example.com/
Unset / wrong
- Webhook shows http://localhost:5678/webhook/...
- Stripe/GitHub callbacks 404 or never fire
- Looks fine inside the editor, fails in prod
Set to your domain
- Webhook shows https://n8n.example.com/webhook/...
- External services reach it over TLS
- Editor URL and callbacks agree
Put a reverse proxy with HTTPS in front (never expose 5678 raw)
Terminate TLS at a reverse proxy and let it forward to n8n on 127.0.0.1:5678. Caddy, Traefik, and Nginx all work; I use Caddy because it fetches and renews Let’s Encrypt certificates on its own, so the whole TLS story is a two-line config:
# Caddyfile
n8n.example.com {
reverse_proxy 127.0.0.1:5678
}
That’s it — Caddy provisions the certificate on first request and keeps it renewed. If you prefer Nginx, the shape is the standard TLS server block proxying to the same upstream, with the WebSocket upgrade headers set so the editor’s live UI works:
server {
listen 443 ssl;
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;
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;
}
}
The rule that matters more than which proxy you pick: never publish port 5678 to the internet raw. TLS terminates at the proxy, and n8n stays firewalled to localhost. This is the same discipline I apply to any self-hosted service that handles secrets — see how to secure MCP servers for the same reverse-proxy-plus-firewall pattern applied to Model Context Protocol endpoints.
Scale with queue mode: EXECUTIONS_MODE=queue, Redis, and worker processes
The default execution mode is regular — the main n8n process runs every workflow itself. That’s fine until one instance can’t keep up: long-running executions block the queue, CPU pegs, and the editor gets sluggish. Queue mode fixes that by separating the roles. The main process enqueues jobs in Redis, and one or more separate worker processes pull jobs off the queue and run them. Queue mode requires both PostgreSQL and Redis — it’s exactly why SQLite was a non-starter.
Reach for this only when a single instance is genuinely saturated, not on day one. When you do, add Redis and a worker service, and flip EXECUTIONS_MODE=queue across all n8n containers:
redis:
image: redis:7
restart: unless-stopped
n8n-worker:
image: docker.n8n.io/n8nio/n8n
restart: unless-stopped
command: worker
environment:
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=${DB_POSTGRESDB_DATABASE}
- DB_POSTGRESDB_USER=${DB_POSTGRESDB_USER}
- DB_POSTGRESDB_PASSWORD=${DB_POSTGRESDB_PASSWORD}
depends_on:
- postgres
- redis
The worker container runs the same image with the worker command instead of the default. Add EXECUTIONS_MODE=queue and QUEUE_BULL_REDIS_HOST=redis to the main n8n service too — both sides need to agree that jobs live in Redis. Scale throughput by running more worker replicas; every worker needs the same N8N_ENCRYPTION_KEY so it can decrypt credentials. Queue mode is the ops-layer companion to workflow design — if you’re building the automations themselves, see build an AI automation with n8n and Claude.
Back up the database, the encryption key, and the .n8n volume
Three things, and all three matter. Back up the PostgreSQL database (workflows, executions, encrypted credentials), the N8N_ENCRYPTION_KEY, and the /home/node/.n8n data volume. The point I can’t stress enough: a database dump without the key is useless — credentials stay encrypted and nothing can read them. Store the key separately from the dump.
# Nightly Postgres dump from the compose stack
docker compose exec -T postgres \
pg_dump -U n8n n8n | gzip > /backups/n8n-$(date +%F).sql.gz
Keep the encryption key in your secrets manager (already done in the .env step), and snapshot the n8n_data volume alongside the SQL dump. Restore is: bring up an empty stack with the same key, load the dump, and start.
Updating n8n: pull the image and recreate the container
Compose makes updates a one-liner. Pull the new image and recreate — your data lives in the Postgres and .n8n volumes, so it survives the container swap:
docker compose pull
docker compose up -d
n8n runs any needed schema migrations automatically on the next start. Back up first (the command above), read the release notes for breaking changes on major bumps, and if you’re on queue mode, pull the worker image too so main and workers stay on the same version.
Production security checklist
The essentials, in one place:
- Don’t expose
5678raw. Bind it to127.0.0.1, terminate TLS at the proxy, and firewall everything else. - Enable owner/auth. Set up the owner account on first launch so the editor isn’t open to anyone who finds the URL.
- Keep secrets in env or secret files, never hard-coded inside a workflow.
- Set and back up
N8N_ENCRYPTION_KEY— off the server, in a vault. - Use PostgreSQL, not SQLite, for anything you care about.
- Get
WEBHOOK_URLright so callbacks and the editor agree on your public HTTPS domain.
If your workflows will hand Mexican customer data to an LLM, add a redaction step before the model call — see redact Mexican PII before the LLM. And the AI agents hub collects the rest of the build-and-secure playbook.
Sources: n8n Docker installation, environment variables reference, and queue mode.