This guide shows how to turn a basic docker compose for n8n install into a production-ready stack. It covers a durable Postgres-based base compose with pinned versions, adding Traefik vs Caddy for automatic HTTPS, scaling with Redis queue mode and workers, and fixing five common failures like missing encryption keys, webhook localhost URLs, volume loss, and OOM kills. It concludes with backup, maintainability, and ownership checklists.

A production-ready docker-compose setup for n8n runs two core services (n8n and Postgres) on persistent named volumes, driven by a .env file that pins the n8n image to a concrete version tag instead of :latest. Add a reverse proxy for automatic HTTPS, and queue mode with Redis and worker containers once execution volume demands it.
SQLite works for a quick test, but it locks the whole database file on writes. Under concurrent webhooks or overlapping workflow executions that lock becomes lost executions and corrupted credentials. Postgres is a client-server database that handles concurrent connections and transactions, which is why PostgreSQL is recommended for production and why the official Docker setup with Postgres uses it by default.
This is the minimal durable stack, taken directly from n8n's own hosting repo:
volumes:
db_storage:
n8n_storage:
services:
postgres:
image: postgres:18
restart: always
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- POSTGRES_NON_ROOT_USER
- POSTGRES_NON_ROOT_PASSWORD
- PGDATA=/var/lib/postgresql/data
volumes:
- db_storage:/var/lib/postgresql/data
-./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
restart: always
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
ports:
- 5678:5678
volumes:
- n8n_storage:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
n8n-runner:
image: n8nio/runners:${N8N_VERSION}
restart: always
environment:
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
depends_on:
- n8n
.env filePut this next to the compose file and never commit it with secrets:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me-strong
POSTGRES_DB=n8ndb
POSTGRES_NON_ROOT_USER=n8n_user
POSTGRES_NON_ROOT_PASSWORD=change-me-also-strong
N8N_VERSION=pinned-version-you-tested
RUNNERS_AUTH_TOKEN=generate-a-long-random-string
The workflow determines the stack. By pinning N8N_VERSION and using ${N8N_VERSION} in both n8n and n8n-runner images, you control exactly when an upgrade happens. Re-pulling :latest six months later can introduce a breaking migration with no rollback plan.
Pinning to :latest in a compose file you'll rerun in six months is how upgrades silently break your workflows.
That base stack runs, but the moment it's reachable from the internet, SSL and routing become non-negotiable.
A common mistake in docker compose for n8n is exposing port 5678 directly and treating HTTPS as an optional later step. A reverse proxy wired into the same compose file is what gives n8n automatic HTTPS, with Traefik as the option n8n itself documents for production use.
The official n8n Docker Compose guide configures two containers: one for n8n, and one to run traefik, an application proxy to manage TLS/SSL certificates and handle routing. That pattern keeps certificate handling inside the stack instead of bolted on elsewhere.
Three options dominate for n8n self-hosters:
traefik.enable=true and router rules to the existing n8n service and Traefik picks them up via the Docker socket. No separate config file to maintain when you add services.n8n.example.com { reverse_proxy n8n:5678 }. It is the first web server to use HTTPS automatically and by default, provisioning and renewing certificates and redirecting HTTP to HTTPS with no extra tooling. Less magic than Traefik, easier to audit in git.For production, use the officially documented Traefik add-on. Add this service to your compose file and keep n8n internal:
services:
traefik:
image: traefik:v3.3
restart: always
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.mytlschallenge.acme.tlschallenge=true
- --certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}
- --certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- traefik_data:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
traefik_data:
Then add labels to the existing n8n service and set the proxy-aware variables:
labels:
- traefik.enable=true
- traefik.http.routers.n8n.rule=Host(`${SUBDOMAIN}.${DOMAIN_NAME}`)
- traefik.http.routers.n8n.tls=true
- traefik.http.routers.n8n.entrypoints=websecure
- traefik.http.routers.n8n.tls.certresolver=mytlschallenge
In .env: DOMAIN_NAME=example.com, SUBDOMAIN=n8n, SSL_EMAIL=user@example.com. In the n8n environment: N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}, N8N_PROTOCOL=https, and WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/ so webhooks, OAuth callbacks, and editor previews generate the public HTTPS URL. Keeping TLS inside the compose file keeps people in control of their own domain and certificates.
n8n's queue mode offloads execution from the main container by setting EXECUTIONS_MODE to queue and routing jobs through Redis, with each worker picking up a configurable number of concurrent jobs.
In this model the main instance only receives webhooks, timers and UI requests. It writes an execution ID to Redis, a worker picks it up, loads the workflow definition from Postgres, runs it, then writes results back to the database and notifies Redis. That decoupling is what prevents long-running research or content workflows from blocking the editor under concurrent load.
You need queue mode when workflows are webhook-heavy, trigger dozens of times per minute, or when multiple users and systems fire executions at once. For a single owner running a few scheduled syncs, the single-container setup from the base stack is enough. Put simply, the workflow determines the stack, not the other way around.
n8n documents the exact variables for queue mode configuration. Set these on main, workers, and webhook processors:
EXECUTIONS_MODE=queue on every n8n serviceQUEUE_BULL_REDIS_HOST and QUEUE_BULL_REDIS_PORT pointing to Redis (defaults to localhost:6379)N8N_ENCRYPTION_KEY identical across all instances so workers can decrypt credentialsQUEUE_BULL_REDIS_DB=0 unless you isolate queuesThe queue mode environment variables reference also defines timeouts and health checks like QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD and N8N_GRACEFUL_SHUTDOWN_TIMEOUT. n8n notes SQLite is not recommended here, Postgres is required, and documents a minimum worker concurrency setting worth reviewing against your own workflow load, so check the current defaults in the queue mode reference before deploying.
Add these to your existing compose file, do not duplicate the postgres and main blocks:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
n8n-worker:
image: n8nio/n8n:1.85.4
restart: unless-stopped
command: worker --concurrency=10
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_BULL_REDIS_PORT=6379
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
Duplicate n8n-worker as n8n-worker-2 etc. to scale horizontally, or adjust the --concurrency flag to match your workload.
The operational checklist becomes your fill-in sheet:
| Field / Step | What to enter | Example |
|---|---|---|
| Redis service image | pinned Redis tag | redis:7-alpine |
| Main n8n EXECUTIONS_MODE | queue | queue |
| Worker command | worker with concurrency flag | worker --concurrency=10 |
| QUEUE_BULL_REDIS_HOST | service name from compose | redis |
| QUEUE_BULL_REDIS_PORT | Redis port | 6379 |
| N8N_ENCRYPTION_KEY | shared key from main, generated once | 4f8a9c2e1b6d47a0f3e8c9d12a5b6e7f0 |
| Database type for queue | Postgres required | postgresdb with host postgres |
| Graceful shutdown | time workers wait on exit, set via N8N_GRACEFUL_SHUTDOWN_TIMEOUT | check the queue mode environment variables reference for the current default |
A stack that scales still has to survive restarts, crashes, and bad upgrades, which is a data-durability problem, not a scaling one.
Common n8n Docker Compose failures cluster into five repeatable patterns that take down production stacks: missing encryption key restart loops, Postgres refusing connections on cold boot, webhook URLs rendering as localhost behind a reverse proxy, complete data loss after docker compose down -v, and container exit 137 from OOM kills.
Every added service is one more thing that can fail quietly. The fixes below come from the cases operators hit first when they move beyond n8n start.
| Symptom | Likely Cause | Fix |
|---|---|---|
| n8n container restart loop, logs show Missing encryption key | N8N_ENCRYPTION_KEY not set or not passed to worker/main | Generate with openssl rand -hex 24, set in .env for all n8n services, keep volume mounted |
| Postgres connection refused / ECONNREFUSED on first up | Compose started n8n before Postgres ready; no healthcheck | Add postgres healthcheck pg_isready and depends_on condition service_healthy for n8n |
| Webhook URLs show http://localhost:5678 in editor | Missing N8N_WEBHOOK_URL and proxy hops behind reverse proxy | Set N8N_WEBHOOK_URL=https://n8n.example.com/ and N8N_PROXY_HOPS=1, forward X-Forwarded-* headers |
| Workflows/credentials gone after docker compose down -v | -v flag deletes named volumes | Never use -v in prod; restore from pg_dump or volume snapshot |
| Container killed with exit 137, no n8n error | No memory limit, large executions cause host OOM killer | Set mem_limit and mem reservation, add healthcheck, split heavy work into sub-workflows |
Encryption key: The restart loop hits hardest after an upgrade or when you add a worker. A thread on the community forum shows the exact log: Error: Missing encryption key. Worker started without the required. Generate once with openssl rand -hex 24, put it in .env as N8N_ENCRYPTION_KEY, and pass the same file to every n8n service. Keep the n8n data volume mounted so the key file on disk isn't the only copy.
Postgres race: Compose starts all services at once unless you tell it not to. Without a healthcheck, n8n tries to connect before Postgres is ready and enters crash-loop. Add a healthcheck: pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} to the postgres service and make n8n depends_on it with condition: service_healthy.
Webhooks behind a proxy: n8n builds the webhook URL from internal protocol and port, so behind Traefik or Caddy it shows http://localhost:5678. You need to set the webhook URL manually with N8N_WEBHOOK_URL=https://n8n.example.com/ and set N8N_PROXY_HOPS=1. The proxy itself must forward X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto.
Data loss from -v: down -v deletes named volumes. That is working as designed, not a bug. In production never use -v. Treat Postgres as the source of truth and schedule a real backup.
OOM kills: Large binary data or many parallel executions push the Node process past the host limit. Linux kills it with exit 137 and Compose restarts it with no log. Add explicit mem_limit and healthcheck to the n8n service so you see it before users do.
For Postgres, run pg_dump from a sidecar or cron job, not from inside the n8n container:
docker compose exec postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB | gzip > /backups/n8n-$(date +%F).sql.gz
Restore to a blank volume with psql after recreating the database. If your host supports volume snapshots, snapshot the postgres volume nightly and keep one dump offsite. Keep a checklist of these jobs alongside your other automation resources so recovery is tested, not theoretical.
Self-hosted n8n stops being a personal project and becomes a production system the moment a missed execution would block enrolment, publishing, or revenue. That failure-mode table is really a preview of what "production-ready" actually means once you strip away the marketing language.
Get practical guidance for Agencies, publishers, content teams, and businesses with repetitive workflows that rely on tools like n8n, Cloudflare, Supabase, PostgreSQL, Webflow, WordPress, or AI services (OpenAI, Claude). Ideal clients are those who need to automate research, writing, document processing, or operational handoffs but want a system that’s transparent, maintainable, and integrated with their existing stack—not a black-box solution..
The shift is ownership. If the compose file only lives on one laptop, if no one knows which image tag is running, if the Postgres backup has never been restored, you have a demo that happens to be live. Once workflows carry business risk, the stack needs the same durability practices as any other service: version control, accountable upgrades, observable health, and a restore you have actually tested.
For teams where n8n has become business-critical, running financial research pipelines, e-learning enrolment flows, or content publishing systems, this is the same kind of durable Cloudflare, Docker, and Postgres architecture that Hesham Mashhour - AI Content Systems builds and maintains directly, and the practical next step is mapping the current process before you scale it further.
Verdict: a docker-compose file that starts n8n is not the same as one you can trust in production; the difference is version pinning, SSL, and a tested backup.
SQLite locks the whole file on writes, so concurrent webhooks can collide and corrupt credentials. The official Postgres example uses postgres:18 with DB_TYPE=postgresdb and DB_POSTGRESDB_HOST=postgres because PostgreSQL is recommended for production.
Pin N8N_VERSION in .env and use docker.n8n.io/n8nio/n8n:${N8N_VERSION} for both n8n and n8n-runner. Pulling :latest months later can apply a breaking migration with no tested rollback.
No. Once Traefik listens on 80:80 and 443:443, keep n8n internal and route via labels like traefik.http.routers.n8n.rule=Host(...). Exposing 5678:5678 directly bypasses the proxy and leaves TLS unenforced.
The -v flag deletes named volumes such as db_storage and n8n_storage, so workflows and credentials disappear. In production never use -v; back up Postgres with pg_dump instead.
The log Error: Missing encryption key. Worker started without the required means N8N_ENCRYPTION_KEY is not shared. Generate once with openssl rand -hex 24, store it in .env, and set the same key on main, workers, and n8n_storage.
n8n builds URLs from its internal host. Set N8N_WEBHOOK_URL=https://n8n.example.com/ and N8N_PROXY_HOPS=1, and make the proxy forward X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto. Note WEBHOOK_URL was deprecated from 2.35.0 and replaced by N8N_WEBHOOK_URL.
Add them when webhooks arrive many times per minute or executions overlap and block the editor. Enable queue mode by setting EXECUTIONS_MODE=queue everywhere, pointing QUEUE_BULL_REDIS_HOST=redis and QUEUE_BULL_REDIS_PORT=6379, and reusing the same Postgres connection.
Traefik discovers services from Docker labels and is documented in n8n's guide for managing TLS certificates and routing. Caddy provides automatic HTTPS that provisions certificates, renews them, and redirects HTTP to HTTPS with a tiny Caddyfile, while community notes say Traefik excels in dynamic Docker environments and NPM wins for GUI-driven simplicity.
AI-powered content systems and workflow automation built around your team’s tools, processes, and goals—designed, implemented, and maintained by a Cambridge-trained automation engineer.
Learn moreI’m a Cambridge-trained MD turned automation engineer.