how-to-guide

Docker Compose for n8n: A Production-Ready Setup Guide

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.

September 11, 2026
·
10
min read
3D render of modular blocks illustrating docker compose for n8n production stack with Postgres, proxy and Redis

The Production-Ready Docker Compose File for n8n

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.

The base compose file

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

Matching .env file

Put 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.

Adding a Reverse Proxy: Traefik vs. Caddy for SSL

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:

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.

Scaling n8n with Queue Mode: Redis and Worker Containers

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.

Required environment variables

n8n documents the exact variables for queue mode configuration. Set these on main, workers, and webhook processors:

  • EXECUTIONS_MODE=queue on every n8n service
  • QUEUE_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 credentials
  • QUEUE_BULL_REDIS_DB=0 unless you isolate queues

The 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.

Compose additions only

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 and How to Fix Them

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

How these break in practice

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.

Backup and restore that actually works

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.

When Self-Hosted n8n Becomes a System Worth Maintaining

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.

Talk to Hesham

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..

Get in touch →

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.

The five checks that make it maintainable

  • Pin every image to an explicit version and commit the compose file and .env.example to git, so upgrades are intentional
  • Put a reverse proxy with automatic SSL in the same stack and force n8n to use that public URL, keep people in control of routing
  • Stay on single-container mode until you see queued or concurrent execution pressure, then add Redis and a worker service
  • Schedule nightly Postgres dumps to off-host storage and restore them to a throwaway container at least once, document the exact commands
  • Set memory limits and healthchecks for n8n, Postgres, and Redis so failures alert instead of lingering
  • Assign one owner for upgrades and keep a short runbook for backup, restore, and rollback

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.

Sources

  1. github.com
  2. community-charts.github.io
  3. docs.n8n.io
  4. Automatic HTTPS - Caddy Documentation
  5. blog.byte-guard.net
  6. Enable queue mode | Deploy | n8n Docs
  7. Queue mode | Deploy | n8n Docs
  8. Configure webhook URLs with reverse proxy
  9. community.n8n.io

Frequently Asked Questions

Why not stay on SQLite if my server already runs Postgres for other apps?

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.

Can I use:latest for the n8n image to get automatic updates?

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.

Do I still need to publish port 5678 when Traefik handles HTTPS?

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.

What happens if I run docker compose down -v on a live stack?

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.

Why does my worker log Missing encryption key and enter a restart loop?

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.

Why do webhook URLs show http://localhost:5678 after adding Traefik or Caddy?

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.

When should I actually add Redis and extra workers?

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.

Which reverse proxy should I choose for n8n automatic HTTPS?

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.

Schedule a call today

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 more
Written by
Hesham Mashhour
Automation Consultant

I’m a Cambridge-trained MD turned automation engineer.