n8n
Self-hosted workflow automation with a Postgres backend.
The stack
Generated output
services:
n8n:
image: docker.n8n.io/n8nio/n8n:1.68.1@sha256:5e8acf8527ddf9d08a4f0e7ac042eb3fb31a6afa071ed2b8be2e47327b0acd49
restart: unless-stopped
depends_on:
- n8n-postgres
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: n8n-postgres
DB_POSTGRESDB_PORT: "5432"
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${DB_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${N8N_HOST}
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_HOST}/
GENERIC_TIMEZONE: ${TZ}
N8N_RUNNERS_ENABLED: "true"
N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
volumes:
- n8n-data:/home/node/.n8n
n8n-postgres:
image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: n8n
POSTGRES_INITDB_ARGS: --data-checksums
volumes:
- n8n-postgres-data:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- pg_isready -U n8n -d n8n
interval: 10s
timeout: 5s
retries: 5
volumes:
n8n-data: {}
n8n-postgres-data: {}
# Caddyfile for n8n
# Replace n8n.example.com with your real domain. Caddy issues TLS automatically.
n8n.example.com {
encode zstd gzip
reverse_proxy n8n:5678
}
# Traefik labels for n8n
# Merge into the n8n service in docker-compose.yml.
# Assumes an external Traefik network named "proxy" and a
# certresolver named "letsencrypt". Replace n8n.example.com.
services:
n8n:
labels:
traefik.enable: "true"
traefik.http.routers.n8n-postgres.rule: Host(`n8n.example.com`)
traefik.http.routers.n8n-postgres.entrypoints: websecure
traefik.http.routers.n8n-postgres.tls: "true"
traefik.http.routers.n8n-postgres.tls.certresolver: letsencrypt
traefik.http.services.n8n-postgres.loadbalancer.server.port: "5678"
networks:
- proxy
- default
networks:
proxy:
external: true
Replace n8n.example.com with your domain. Generate secrets below.
Environment
.env with generated secrets
Secrets are generated in your browser via crypto.getRandomValues. Nothing is sent anywhere.
Server-rendered .env template
# Public hostname n8n is served on. Used for webhook URLs. N8N_HOST=n8n.example.com # Timezone for cron triggers (IANA tz name). TZ=UTC # PostgreSQL password for the n8n user. DB_PASSWORD= # Encrypts stored credentials. NEVER change this after first boot — all stored credentials become unreadable. N8N_ENCRYPTION_KEY=
About
What is n8n?
n8n is an open-source workflow automation tool — Zapier or Make.com that you run yourself. Drag nodes onto a canvas, connect APIs, run on schedule or webhook. The default SQLite backend is fine for trying it out, but anything you actually rely on belongs on Postgres: SQLite locks the entire database during execution writes, which becomes a bottleneck as soon as you have a few concurrent workflows. This stack runs n8n with a dedicated Postgres 16 instance, persistent volumes for both, and a hardened set of environment defaults: encryption key for credentials, runners enabled, and the database not exposed to the host. The reverse proxy block sends `n8n.example.com` to the n8n container on port 5678.
Requirements
Before you start
- 1 GB RAM minimum, 2 GB recommended.
- Docker 24+ and Docker Compose v2.
- A persistent disk — losing the n8n volume means losing all workflows and credentials.
- Stable system time — cron triggers depend on it.
Deploy
How to deploy
- Generate `DB_PASSWORD` and `N8N_ENCRYPTION_KEY` via the "Generate secrets" button.
- Back up `N8N_ENCRYPTION_KEY` somewhere outside the host now. You cannot recover credentials without it.
- Set `N8N_HOST` to the public hostname (e.g. `n8n.example.com`).
- Start the stack: `docker compose up -d`.
- Open `https://n8n.example.com` and create the owner account on first visit.
Errors
Common errors & fixes
Webhook URLs in workflows show `http://localhost:5678/...` instead of the public URL.
You did not set `WEBHOOK_URL` (or set `N8N_HOST` wrong). The compose template sets `WEBHOOK_URL=https://${N8N_HOST}/`. Verify your `.env` has the correct `N8N_HOST`.
After restart, all stored credentials show "could not decrypt".
Your `N8N_ENCRYPTION_KEY` changed (likely auto-generated on first boot, then replaced). Restore the original key from backup. If lost, all credentials must be re-entered.
"relation does not exist" errors after upgrading.
n8n runs migrations on startup. If you skipped a major version, jump to the intermediate release first. Check the version-migration notes in n8n docs.
Long-running workflows time out at exactly 5 minutes.
Your reverse proxy timeout is shorter than the workflow. Increase Caddy `request_timeout` or the Traefik `responseHeaderTimeout` for this route.
Limitations
Honest limitations
- Single-instance only in this compose — n8n queue mode with workers requires Redis and is out of scope here.
- No external SMTP configured by default — workflows that send email need credentials added via the UI.
- Postgres is intentionally not exposed to the host. Connect via `docker compose exec` if you need psql.
FAQ
Frequently asked
Why Postgres instead of the default SQLite?+
SQLite is fine for trying n8n, but it locks the database file during writes. With more than a handful of concurrent executions you will see "database is locked" errors. Postgres scales further and is the upstream recommendation for production.
Can I lose my encryption key and still keep my workflows?+
Yes — workflows themselves are stored plaintext. Only stored credentials (API keys, OAuth tokens) need the encryption key. Without the key those become unrecoverable and must be re-entered.
How do I upgrade n8n safely?+
Stop the stack, snapshot both the `n8n-data` and `n8n-postgres-data` volumes, bump the image tag in compose, start the stack. n8n will run migrations on boot. Read the release notes for breaking changes.
Do I need the reverse proxy?+
For real use, yes — n8n issues webhook URLs that must be reachable over HTTPS. The compose file does not expose port 5678 publicly; the proxy block is what makes n8n reachable.
Related