Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

n8n Docker Compose Example: A Setup That Survives Restarts

Sean

Platform Writer

Aug 04, 2026
9 min read

The default n8n container stores workflows in SQLite inside the container and generates a random encryption key on first boot. Both are fine for a demo and both lose data in production.

n8n Docker Compose Example: A Setup That Survives Restarts

There are plenty of one-service n8n compose files around, and they all work for about a week. Then the container gets recreated during an update and the workflows are gone, or the encryption key changes and every stored credential becomes unreadable.

This is the setup that avoids both, built up in stages so you can stop at the level of complexity you actually need.

Table of contents

The minimum viable setup

Start here to confirm things work, then move on. Note the named volume and the explicit encryption key — those two lines are what separate this from the version that loses your data.

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - GENERIC_TIMEZONE=Europe/London
      - TZ=Europe/London
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_SECURE_COOKIE=false
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  n8n_data:

Generate the encryption key once and store it in a .env file beside the compose file. This is the single most important line in the whole configuration.

echo "N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env

If you let n8n generate its own key, it writes it into the config file inside the volume. Lose the volume and every stored credential becomes permanently undecryptable — the workflows survive but every connection they use has to be re-entered by hand.

Adding Postgres, which you should do before you have real workflows

n8n defaults to SQLite. That works, but execution history grows quickly and SQLite’s single-writer limit becomes visible once workflows run concurrently. Postgres is the supported path and migrating later is more work than starting there.

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=n8n
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=n8n
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "5678:5678"
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=Europe/London
      - TZ=Europe/London
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  n8n_data:
  postgres_data:

The healthcheck with condition: service_healthy matters more than it looks. Without it n8n starts before Postgres is accepting connections, fails, and restarts — usually recovering, but noisily and sometimes not.

Keep the n8n volume even when using Postgres. It holds the config file, custom nodes, and binary data references. Postgres holds the workflows and execution history.

Serving it on a domain with HTTPS

Running on port 5678 over plain HTTP is fine locally. The moment it is reachable from the internet — which it must be, for webhooks to work — it needs TLS and n8n needs to know its own public URL.

  n8n:
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - N8N_EDITOR_BASE_URL=https://n8n.example.com/
      - N8N_PROXY_HOPS=1

WEBHOOK_URL is the one people miss. n8n generates webhook URLs to hand to external services, and without this it advertises localhost:5678 — which no third party can reach. Symptom: webhooks that work in the editor’s test mode and never fire in production.

N8N_PROXY_HOPS tells n8n how many reverse proxies sit in front, so it reads the correct client IP from the forwarded headers. Get it wrong and rate limiting sees every request as coming from the proxy.

Put a reverse proxy in front — Caddy, Traefik, or nginx — to terminate TLS. n8n does not manage certificates itself, and exposing it directly on 443 is not a supported configuration.

Queue mode, for when one instance is not enough

By default n8n runs everything in one process. Under load, a long-running workflow blocks others and a restart kills whatever was mid-execution. Queue mode adds Redis and separate worker containers.

services:
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - EXECUTIONS_MODE=queue
      - QUEUE_BULL_REDIS_HOST=redis
      - QUEUE_HEALTH_CHECK_ACTIVE=true
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
    depends_on:
      redis:
        condition: service_healthy

  n8n-worker:
    image: docker.n8n.io/n8nio/n8n:latest
    restart: unless-stopped
    command: worker
    deploy:
      replicas: 3
    environment:
      - EXECUTIONS_MODE=queue
      - QUEUE_BULL_REDIS_HOST=redis
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}

volumes:
  redis_data:

Every worker needs the identical database configuration and — critically — the same encryption key. A worker with a different key cannot decrypt credentials and every workflow using them fails with errors that do not obviously point at the cause.

Do not reach for queue mode early. It triples the moving parts and most self-hosted installations never need it. Add it when a single instance is genuinely saturated, not in anticipation.

Pruning execution data before it fills the disk

n8n stores every execution by default, including full input and output payloads for each node. A workflow running each minute with a sizeable payload will fill a disk faster than you expect, and the first symptom is usually a database that has stopped accepting writes.

  n8n:
    environment:
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=336
      - EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
      - EXECUTIONS_DATA_SAVE_ON_SUCCESS=all
      - EXECUTIONS_DATA_SAVE_ON_ERROR=all
      - EXECUTIONS_DATA_SAVE_ON_PROGRESS=false

EXECUTIONS_DATA_MAX_AGE is in hours, so 336 is fourteen days. Set both the age and the count limits — whichever triggers first does the pruning.

If disk is genuinely tight, set EXECUTIONS_DATA_SAVE_ON_SUCCESS=none and keep only failures. You lose the audit trail for successful runs but keep exactly the data you need for debugging, which is usually the right trade.

Whatever you configure, back up the Postgres volume on a schedule. Pruning limits growth; it does not protect you from a corrupted volume or an accidental docker compose down -v, which removes named volumes and takes everything with it.

How this fits the rest of the stack

A self-hosted n8n that survives contact with production is really four services — the editor, the database, a cache, and a reverse proxy — plus backups, TLS, and a place to keep the encryption key safe. That is a reasonable weekend and a permanent maintenance commitment. RunxBuild runs n8n alongside managed Postgres and Redis on one deployment path, with persistent storage and environment variables handled as platform settings rather than compose files, and the RunxBuild hosting calculator shows what that combination costs against the VPS you would otherwise be maintaining.

Useful related references:

FAQ

Why did n8n lose my credentials after an update?

The encryption key changed. If N8N_ENCRYPTION_KEY is not set explicitly, n8n generates one and stores it in the config file inside the volume. Lose that volume and stored credentials become permanently undecryptable. Always set the key from a .env file.

Do I need Postgres or is SQLite enough?

SQLite works for light single-user use, but execution history grows fast and its single-writer limit shows under concurrency. Postgres is the supported production path, and starting there is much easier than migrating later.

Why do my webhooks work in test mode but not in production?

WEBHOOK_URL is not set. n8n advertises the URL it thinks it has, which defaults to localhost — unreachable for external services. Set WEBHOOK_URL to the public HTTPS address.

When should I enable queue mode?

Only when a single instance is genuinely saturated: long-running workflows blocking others, or executions lost on restart. It adds Redis and separate worker containers, which is real operational overhead most installations never need.

How do I stop n8n filling the disk?

Enable pruning with EXECUTIONS_DATA_PRUNE=true and set EXECUTIONS_DATA_MAX_AGE in hours plus EXECUTIONS_DATA_PRUNE_MAX_COUNT. If disk is tight, stop saving successful executions and keep only errors.

#n8n Docker Compose Example#n8n#Docker Compose#Self-Hosting#Automation