Self-Hosting n8n with Docker Compose
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 viaN8N_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:
- 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. - Back it up. Lose the key, lose all credentials.
- 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, usengrokorcloudflaredfor 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:
Deploymentfor n8n with the same env varsDeployment+ PVC for Postgres (or a managed Postgres)Serviceof type ClusterIPIngresswith cert-manager for TLSSecretfor credentials
Same shape. Compose is faster to bootstrap; K8s is right if it’s already your platform.
Backup strategy
Two things to back up:
- Postgres database (
pg_dumpdaily, retain 30 days). All workflow definitions, executions, credentials encrypted-at-rest. 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.