background-shape
Self-Hosting n8n with Docker Compose
May 4, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — n8n in Docker Compose: one n8n container, one Postgres container (don’t use the default SQLite for prod), persistent volumes for .n8n/, a reverse proxy (Traefik or Caddy) for TLS. Basic auth or webhook-only via N8N_BASIC_AUTH_* envs. A working production setup is under 60 lines of Compose.

After the case for n8n, this post is the production-shaped setup. Not the SQLite-on-a-laptop quickstart from n8n’s docs — the actual Compose stack I’m running. Postgres backing, persistent volumes, reverse proxy with TLS, env vars for credentials encryption.

This is the foundation. Every workflow post in May assumes you have this running.

The Compose file

# docker-compose.yml
name: n8n-stack

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

  n8n:
    image: n8nio/n8n:0.176.0
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      # Database
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n_app
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_APP_PASSWORD}

      # URLs
      N8N_HOST: ${N8N_HOST}
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://${N8N_HOST}/

      # Auth
      N8N_BASIC_AUTH_ACTIVE: "true"
      N8N_BASIC_AUTH_USER: ${N8N_USER}
      N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD}

      # Credentials encryption (CRITICAL — see below)
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}

      # Execution
      EXECUTIONS_MODE: regular
      EXECUTIONS_DATA_SAVE_ON_ERROR: all
      EXECUTIONS_DATA_SAVE_ON_SUCCESS: all
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: 720    # 30 days

      # Timezone (n8n schedule nodes use this)
      GENERIC_TIMEZONE: Asia/Jakarta
      TZ: Asia/Jakarta

      # Logs
      N8N_LOG_LEVEL: info
      N8N_LOG_OUTPUT: console
    volumes:
      - n8n-data:/home/node/.n8n
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.n8n.rule=Host(`${N8N_HOST}`)"
      - "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
      - "traefik.http.services.n8n.loadbalancer.server.port=5678"

  traefik:
    image: traefik:v2.6
    restart: unless-stopped
    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.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-letsencrypt:/letsencrypt

volumes:
  postgres-data:
  n8n-data:
  traefik-letsencrypt:

The .env next to it:

N8N_HOST=n8n.internal.example.com
N8N_USER=admin
N8N_PASSWORD=<strong-random>
N8N_ENCRYPTION_KEY=<32-byte-hex>
POSTGRES_PASSWORD=<strong-random>
POSTGRES_APP_PASSWORD=<strong-random>
ACME_EMAIL=ops@example.com

Generate the encryption key once: openssl rand -hex 32. Treat it like a long-term secret — losing it means decryption of stored credentials is gone forever.

Bring it up

docker compose up -d
docker compose logs -f n8n

DNS records for n8n.internal.example.com should point at this host. Traefik handles the Let’s Encrypt cert provisioning automatically; first request takes a few seconds while ACME validates.

Open https://n8n.internal.example.com. Basic auth prompt, then n8n’s dashboard.

Why Postgres and not SQLite

n8n’s quickstart uses SQLite. Don’t. Reasons:

  • SQLite locks the whole DB on writes; concurrent workflows serialize
  • No backup story beyond “copy the file” — error-prone in production
  • Can’t run multi-instance n8n with SQLite (each instance has its own DB)
  • Migrations occasionally bite under SQLite

Postgres takes 10 more lines of Compose and solves all of it. Use Postgres.

Why a separate non-root Postgres user

In the Compose above, the Postgres container starts as n8n (superuser) which creates the database, then n8n’s app connects as n8n_app (non-superuser) for its actual work. Sets you up for least-privilege and removes one footgun. n8n’s official Docker docs include the init script that creates this user; reference it in docker-entrypoint-initdb.d if your image doesn’t do it automatically.

For a small team this is overkill on day one. It’s also free insurance.

The encryption key — critical

N8N_ENCRYPTION_KEY encrypts credentials (API tokens, OAuth secrets) at rest in the Postgres database. Three rules:

  1. Set it explicitly. If you don’t, n8n generates one at first boot and stores it in /home/node/.n8n/config. That’s fine until you migrate, then you discover all your credentials are encrypted with a key you can’t reproduce.
  2. Back it up. Lose the key, lose all credentials.
  3. Don’t rotate it lightly. Rotating means re-encrypting every stored credential — n8n has tooling for it but it’s a real operation.

Generate once, store in your secrets manager, never commit to git.

Webhook URLs

Workflows in n8n can have webhook triggers. By default, n8n constructs webhook URLs from WEBHOOK_URL (set above). External services (Stripe, GitHub, Slack) need to be able to reach this URL.

Three patterns:

  • Direct exposure — n8n is on a public domain (n8n.example.com). External services hit it directly. Simplest for production.
  • Internal-only n8n + tunnel for dev — n8n on n8n.internal.example.com. For local dev where external services need to reach you, use ngrok or cloudflared for testing.
  • Webhook receiver in front — a separate small service receives external webhooks, validates, then forwards to n8n via internal HTTP. Adds operational complexity; rarely worth it.

For most teams: public DNS + Traefik + basic auth on the UI (webhooks have their own auth via secret URL paths). That’s the default in the Compose above.

Running on Kubernetes instead

For teams already on K8s, an equivalent setup:

  • Deployment for n8n with the same env vars
  • Deployment + PVC for Postgres (or a managed Postgres)
  • Service of type ClusterIP
  • Ingress with cert-manager for TLS
  • Secret for credentials

Same shape. Compose is faster to bootstrap; K8s is right if it’s already your platform.

Backup strategy

Two things to back up:

  1. Postgres database (pg_dump daily, retain 30 days). All workflow definitions, executions, credentials encrypted-at-rest.
  2. N8N_ENCRYPTION_KEY (in your secrets manager, separately from the DB backup).

Restore = create new Postgres, restore dump, set N8N_ENCRYPTION_KEY to original value, bring n8n up. Tested quarterly.

Common Pitfalls

Not setting N8N_ENCRYPTION_KEY. Auto-generated key lives in a file; migrating instances becomes painful. Set explicitly.

Default SQLite in production. As covered. Use Postgres.

Forgetting TZ and GENERIC_TIMEZONE. Scheduled triggers fire in container time; without TZ, that’s UTC. Your “9 AM” job runs at 4 PM WIB. Confusing.

Public n8n without basic auth. Anyone hits the UI; bad day. Always N8N_BASIC_AUTH_ACTIVE=true.

Skipping EXECUTIONS_DATA_PRUNE. Execution history grows unbounded. Set max age so the DB doesn’t fill the disk.

Treating the data volume as ephemeral. Workflows, credentials, executions all live in Postgres + /home/node/.n8n. Both need persistent volumes.

Running with --privileged or root. Unnecessary. The default user works.

Wrapping Up

Sixty lines of Compose, three minutes to bring up, all the production foundations: Postgres backing, TLS, basic auth, credentials encryption, execution pruning, persistent volumes. Friday: actually building workflows — triggers, nodes, the visual model, and where it breaks down to JS.