how-to-guide

Docker Compose for n8n: A Production-Ready Setup Guide

A tested n8n 2.38.7 setup with PostgreSQL, external task runners, optional Caddy HTTPS and Redis workers, plus a practical backup and restore check.

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 dependable n8n deployment needs more than a container that starts. It needs persistent data, a backed-up encryption key, restricted network access, a tested upgrade path, and a recovery procedure. Start with PostgreSQL and one n8n process; add a queue when measured execution pressure justifies the extra services.

This guide targets n8n 2.38.7, with its matching external task runner. It is a tested starting configuration, not a guarantee for every workload. The examples are for a new installation. Do not attach an existing database volume to a different PostgreSQL major version without a migration plan.

The base stack binds the editor to the host's loopback interface. It is suitable for local verification and SSH tunnelling. The later proxy configuration enables public HTTPS access. Until that is configured, the editor is intentionally unavailable on the host's public IP.

You need Docker Engine, Docker Compose 2.24.4 or later (the queue override uses !reset), and enough memory for n8n, PostgreSQL and the external runner. Start with at least 4 GB available for a small test and measure real execution memory before sizing production.

Create the project and secrets

Create an empty directory and save the files below there. Copy .env.example to .env, replace all four password/token placeholders with separate random values, and protect the file with chmod 600 .env. Generate each value with openssl rand -hex 32. Keep .env out of Git and preserve the encryption key in your password manager or encrypted backup store.

Never change N8N_ENCRYPTION_KEY on an existing installation as a casual reset: existing credentials were encrypted with that key.

.env.example

N8N_VERSION=2.38.7
POSTGRES_PASSWORD=replace-with-random-hex
N8N_DB_PASSWORD=replace-with-different-random-hex
RUNNERS_AUTH_TOKEN=replace-with-different-random-hex
N8N_ENCRYPTION_KEY=replace-with-different-random-hex
N8N_LOCAL_PORT=5678
N8N_HOST=localhost
N8N_PROTOCOL=http
N8N_PUBLIC_URL=http://localhost:5678/
N8N_PROXY_HOPS=0
N8N_SECURE_COOKIE=false

The explicit n8n version makes application upgrades intentional. The PostgreSQL and Redis tags used here receive minor updates within their major versions; use image digests when you need byte-for-byte reproducibility. Record the tested image digests in your deployment notes.

compose.yml

services:
  postgres:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
      POSTGRES_DB: n8n
      POSTGRES_NON_ROOT_USER: n8n
      POSTGRES_NON_ROOT_PASSWORD: ${N8N_DB_PASSWORD:?Set N8N_DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql
      - ./init-data.sql:/docker-entrypoint-initdb.d/init-data.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d n8n"]
      interval: 5s
      timeout: 5s
      retries: 20

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION:?Set N8N_VERSION}
    restart: unless-stopped
    environment: &n8n_environment
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD:?Set N8N_DB_PASSWORD}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:?Set N8N_ENCRYPTION_KEY}
      N8N_RUNNERS_MODE: external
      N8N_RUNNERS_AUTH_TOKEN: ${RUNNERS_AUTH_TOKEN:?Set RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
      N8N_HOST: ${N8N_HOST:-localhost}
      N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
      N8N_WEBHOOK_URL: ${N8N_PUBLIC_URL:-http://localhost:5678/}
      N8N_EDITOR_BASE_URL: ${N8N_PUBLIC_URL:-http://localhost:5678/}
      N8N_PROXY_HOPS: ${N8N_PROXY_HOPS:-0}
      N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
      N8N_DIAGNOSTICS_ENABLED: "false"
      N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: 336
    ports:
      - "127.0.0.1:${N8N_LOCAL_PORT:-5678}:5678"
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5678/healthz/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      start_period: 60s
      retries: 12

  runner:
    image: n8nio/runners:${N8N_VERSION:?Set N8N_VERSION}
    restart: unless-stopped
    environment:
      N8N_RUNNERS_AUTH_TOKEN: ${RUNNERS_AUTH_TOKEN:?Set RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
    depends_on:
      n8n:
        condition: service_healthy

volumes:
  postgres_data:
  n8n_data:

PostgreSQL 18 stores its cluster under a versioned subdirectory of /var/lib/postgresql; this volume mount follows that layout. The database and runner broker have no published host ports. n8n connects as a dedicated application role, not the PostgreSQL administrator.

init-data.sql

\getenv app_user POSTGRES_NON_ROOT_USER
\getenv app_password POSTGRES_NON_ROOT_PASSWORD
CREATE USER :"app_user" WITH PASSWORD :'app_password';
GRANT CONNECT, TEMPORARY ON DATABASE n8n TO :"app_user";
GRANT USAGE, CREATE ON SCHEMA public TO :"app_user";

The official PostgreSQL image runs this SQL only when initializing an empty data directory. Editing the environment later does not recreate a role or change its password. On an existing installation, update the database role deliberately and update n8n's matching secret together.

Start and check the base stack

docker compose config --quiet
docker compose up -d --wait --wait-timeout 180
curl --fail http://127.0.0.1:5678/healthz/readiness
docker compose logs --tail=30 runner

Complete owner setup at http://localhost:5678. Create a test workflow with a Webhook trigger followed by a Code node that returns [{json: {answer: 6 * 7}}]. Publish the workflow and call its production webhook URL. A response containing 42 checks the execution path through the external runner; a green container alone does not.

Adding a Reverse Proxy: Traefik vs. Caddy for SSL

Both Traefik and Caddy can terminate HTTPS and route requests to n8n. Traefik suits an existing platform built around Docker labels and automatic service discovery. Caddy is a concise choice for one explicit hostname. The complete optional example here uses Caddy.

Point your chosen subdomain to the server, allow inbound ports 80 and 443, and keep other public access restricted. Set these values in .env, using your real hostname and certificate contact address:

N8N_HOST=n8n.example.com
N8N_PROTOCOL=https
N8N_PUBLIC_URL=https://n8n.example.com/
N8N_PROXY_HOPS=1
N8N_SECURE_COOKIE=true
ACME_EMAIL=you@example.com

Save this as Caddyfile:

{
    email {$ACME_EMAIL}
}
{$N8N_HOST} {
    reverse_proxy n8n:5678
}

Save this as compose.proxy.yml:

services:
  caddy:
    image: caddy:2.10.2-alpine
    restart: unless-stopped
    environment:
      N8N_HOST: ${N8N_HOST:?Set your public hostname}
      ACME_EMAIL: ${ACME_EMAIL:?Set your certificate contact email}
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      n8n:
        condition: service_healthy

volumes:
  caddy_data:
  caddy_config:
docker compose -f compose.yml -f compose.proxy.yml config --quiet
docker compose -f compose.yml -f compose.proxy.yml up -d --wait

Caddy obtains and renews the certificate and forwards the request to n8n on the Docker network. The remaining loopback binding cannot bypass TLS from the public Internet. Verify HTTPS, secure cookies, editor access and the generated webhook URL through your actual hostname.

For the tested n8n release, N8N_WEBHOOK_URL sets the public webhook URL; WEBHOOK_URL is the deprecated older name. N8N_EDITOR_BASE_URL separately sets the editor's externally reachable address. N8N_PROXY_HOPS=1 assumes exactly one reverse proxy. If you add a CDN or another proxy, review the entire forwarded-header and trust chain against the official reverse-proxy configuration.

Scaling n8n with Queue Mode: Redis and Worker Containers

Queue mode lets the main instance receive triggers and hand execution jobs to workers through Redis. Workers read and update the same PostgreSQL database. This helps isolate execution load, but adds network hops and services to operate. Measure queue wait time, execution duration, failures and memory before adding more workers.

Save the following as compose.queue.yml. It extends the tested base configuration and provides both the Redis volume declaration and a separate runner for the worker:

services:
  n8n:
    environment: &queue_environment
      EXECUTIONS_MODE: queue
      QUEUE_BULL_REDIS_HOST: redis
      QUEUE_BULL_REDIS_PORT: 6379
      OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS: "true"
    depends_on:
      redis:
        condition: service_healthy

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 12

  worker:
    extends:
      file: compose.yml
      service: n8n
    command: ["worker", "--concurrency=5"]
    ports: !reset []
    volumes: !reset []
    environment:
      <<: *queue_environment
      QUEUE_HEALTH_CHECK_ACTIVE: "true"
    depends_on:
      n8n:
        condition: service_healthy
      redis:
        condition: service_healthy

  worker-runner:
    image: n8nio/runners:${N8N_VERSION:?Set N8N_VERSION}
    restart: unless-stopped
    environment:
      N8N_RUNNERS_AUTH_TOKEN: ${RUNNERS_AUTH_TOKEN:?Set RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_TASK_BROKER_URI: http://worker:5679
    depends_on:
      worker:
        condition: service_healthy

volumes:
  redis_data:

Start queue mode with both files, and keep using both files for future lifecycle commands:

docker compose -f compose.yml -f compose.queue.yml config --quiet
docker compose -f compose.yml -f compose.queue.yml up -d --wait --wait-timeout 180

For public HTTPS, include all three: -f compose.yml -f compose.queue.yml -f compose.proxy.yml.

Every n8n process uses the same tested version, database credentials and encryption key. Manual executions are offloaded to workers. Each worker has its own external task runner; the main runner can remain connected. The worker inherits the application configuration but clears the editor's host-port and shared-volume bindings.

The concurrency of five is a starting point, not a throughput promise. Increasing it may exhaust database connections or memory. Redis append-only persistence improves recovery of queued state, but external writes still need idempotency because retries can repeat side effects.

This example verifies JSON workflows. Do not assume local filesystem binary storage will work across queue workers. Choose a supported shared binary-data storage mode and check its edition requirements before deploying document or media workloads. See queue-mode configuration and external task runners.

Common n8n Docker Compose Failures and How to Fix Them

Symptom Check Repair
Database login fails Did the initialization SQL run on an empty volume? Does the role exist? Inspect PostgreSQL logs; align the application-role password and n8n secret. Changing .env alone does not change an existing role.
Worker cannot decrypt credentials Do main and worker have the original encryption key? Restore the correct key; do not generate a replacement and expect old credentials to decrypt.
Webhook URLs show localhost Is the public URL configured on the running container? Set N8N_WEBHOOK_URL, the editor URL and the correct proxy-hop count; recreate the service.
Code node times out Is the runner connected to its own broker with the matching token and version? Check both runner and broker logs and network access.
Container exits with code 137 Was it killed for memory, or manually with SIGKILL? Inspect the container state and host logs. Measure and limit concurrency; a memory limit alone does not prevent OOM failures.
Data disappeared after down -v Were named volumes removed? Restore a verified backup and original key. Do not use -v against a live installation.

Backup and restore that actually works

Use PostgreSQL's logical backup tools. The following avoids a pseudo-TTY and creates a custom-format archive. The database identity is explicit, so it does not depend on .env variables being exported into your shell.

umask 077
mkdir -p backups
docker compose exec -T postgres pg_dump -U postgres -d n8n --format=custom > backups/n8n.dump

Check the command's exit status before accepting the archive, use timestamped filenames in your scheduled job, and copy the backup to encrypted off-host storage. Retain the original encryption key and Compose configuration separately. Database backups do not include external binary files or object storage.

Test restoration into a new disposable database, never over the live one:

docker compose exec -T postgres createdb -U postgres n8n_restore_check
docker compose exec -T postgres pg_restore -U postgres --exit-on-error --dbname=n8n_restore_check < backups/n8n.dump
docker compose exec -T postgres psql -U postgres -d n8n_restore_check -c 'SELECT count(*) FROM workflow_entity;'

A restore test should also start a separate isolated n8n instance with the original key, disable or isolate outbound side effects, and verify sample credentials and workflows. The row-count check confirms database restoration; it cannot prove every external integration still works. Never mount a raw live PostgreSQL data-directory copy as if it were a transactionally consistent backup.

When Self-Hosted n8n Becomes a System Worth Maintaining

The configuration above covers a reproducible starting point. Production also needs someone responsible for upgrades, tested restore procedures, execution-data retention, certificate renewal, alerts, capacity and access control. Record the version and image digests before upgrading, preserve a pre-upgrade backup, test migrations on a copy, and plan rollback around the database as well as the container image.

For a small system, a single n8n instance with an external runner may be enough. Queue mode should solve observed pressure. It is not a substitute for workflow design, idempotent writes or monitoring.

If you want help with that operating plan, explore n8n automation services, ongoing support, or book an introductory call.

Frequently asked questions

Should every installation use PostgreSQL?

SQLite can suit a small installation. This guide uses PostgreSQL for a clear application-role boundary and a path to queue mode. Concurrent SQLite writes can contend; that is not evidence that normal concurrency automatically corrupts credentials.

Can I use the latest image tag for automatic upgrades?

Use an explicit n8n version and the same runner version, then upgrade deliberately after testing. Use digests when you require immutable image references. Re-pulling a floating tag is not a rollback strategy.

Do I need to expose port 5678 publicly?

No. The example exposes it only on loopback. Caddy reaches n8n on the internal Docker network and is the public HTTPS entry point.

Why did editing the database password in .env break login?

The PostgreSQL image only applies the initialization script to an empty data directory. For an existing database, change the role's password and update the matching application secret together.

Does queue mode need a runner for every worker?

Yes, when using external task runners. Each worker's runner must connect to that worker's broker using the shared runner token and matching n8n version.

Which webhook environment variable should I use?

The tested n8n 2.38.7 configuration uses N8N_WEBHOOK_URL. The older WEBHOOK_URL name is deprecated from 2.35.0. Match configuration to the version you deploy.

Is a successful pg_dump enough to prove recovery?

No. Restore it into a disposable database, check the data, and test an isolated application with the original encryption key. Keep external binary data and integration configuration in the recovery plan.

Has this guide been tested?

The review exercised PostgreSQL initialization, n8n startup, external Code-node execution, queue-worker execution and a logical database restore in an isolated Docker project. Public DNS and ACME certificate issuance must be verified on your own hostname.

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.

Book an automation call
Written by
Hesham Mashhour
Automation Consultant

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