Here is a production-oriented docker-compose.yml that pairs n8n with PostgreSQL. This is the n8n postgres docker compose pattern we recommend over the default SQLite store, because Postgres handles concurrent executions and large workflow histories far better.
Create a project directory, then save this as docker-compose.yml:
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=changeme_strong_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
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=changeme_strong_password
- N8N_ENCRYPTION_KEY=generate_a_long_random_string_here
- N8N_HOST=n8n.yourcompany.com
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://n8n.yourcompany.com/
- GENERIC_TIMEZONE=Europe/London
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
That is the full n8n compose file. The next sections explain the parts that trip people up.
The environment block is where self-hosted n8n succeeds or fails. Each variable maps to a specific behavior, and getting two of them wrong — the encryption key and the webhook URL — causes the most common support tickets.
DB_TYPE=postgresdb plus the DB_POSTGRESDB_* values tell n8n to use Postgres instead of the bundled SQLite file. The host is postgres — the service name from the compose file, resolved over the private network, not localhost.N8N_ENCRYPTION_KEY encrypts stored credentials. Set it explicitly and never change it. If you omit it, n8n generates one inside the volume; if you later lose that volume, every saved credential becomes unreadable.N8N_HOST, N8N_PROTOCOL, and WEBHOOK_URL must reflect your public domain. If WEBHOOK_URL is wrong, external services will call back to the wrong address and your trigger nodes will silently never fire.GENERIC_TIMEZONE controls how Cron and Schedule nodes interpret times. Set it to your operating timezone to avoid jobs running an hour off.
Generate a strong encryption key with openssl rand -hex 24 and paste the result into N8N_ENCRYPTION_KEY. Store it somewhere safe — a password manager — because you will need it if you ever rebuild the stack from a database backup.
Bring everything up in detached mode, then confirm both containers are healthy before you trust the instance.
- From the directory containing your compose file, run
docker compose up -d. Compose pulls the images, creates the network and volumes, and starts Postgres first thanks to the depends_on health condition. - Check status with
docker compose ps. Both services should show running, and postgres should report healthy. - Tail the logs with
docker compose logs -f n8n. Wait for the line Editor is now accessible via before opening the UI. - Visit
http://your-server-ip:5678 (or your domain behind a proxy). Create the owner account on first load.
If n8n keeps restarting, the logs almost always point to a database connection error — usually a password mismatch between the two services or a host value that is not the Postgres service name.
For anything beyond local testing you should terminate TLS in front of n8n rather than exposing port 5678 directly. A reverse proxy gives you HTTPS, a clean domain, and a single public entry point.
- Caddy is the simplest: a two-line Caddyfile (
n8n.yourcompany.com { reverse_proxy n8n:5678 }) gets you automatic Let's Encrypt certificates with zero manual renewal. - Traefik integrates natively with Compose via container labels and is excellent if you run several services on one host.
- Nginx is the most familiar but requires you to manage certificates with Certbot yourself.
Whichever you pick, add the proxy as another service in the same compose file so it shares the private network and can reach n8n by service name. Then drop the public ports mapping on the n8n service — only the proxy needs to be internet-facing. Keep N8N_PROTOCOL=https and the WEBHOOK_URL on https:// so generated webhook and OAuth URLs match what the outside world sees.
Because everything lives in two named volumes, backups are straightforward: dump the Postgres database and archive the n8n data volume.
- Database dump:
docker compose exec postgres pg_dump -U n8n n8n > n8n_backup.sql. This captures all workflows, executions, and credential records. - Volume archive: stop the stack and tar the volume, or use a helper container to copy
/var/lib/docker/volumes/<project>_postgres_data and _n8n_data to safe storage. - Restore: recreate the stack with the same
N8N_ENCRYPTION_KEY, then pipe the SQL dump back in with psql. Without the original key, restored credentials will not decrypt.
Automate this with a nightly cron job that runs pg_dump and ships the file off-server. We treat the encryption key and the database dump as a pair — losing either one breaks a restore.
n8n ships updates frequently, and Compose makes upgrades a three-step ritual. The golden rule: back up first, then pull, then recreate.
- Run your database backup (see above) so you can roll back if a workflow behaves differently.
- Pull the new image:
docker compose pull n8n. - Recreate the container:
docker compose up -d. Your volumes persist, so workflows and credentials carry over untouched.
Pinning a specific version tag instead of :latest — for example n8nio/n8n:1.62.0 — is the safer production habit. It makes upgrades intentional and prevents an unexpected restart from jumping several versions at once. Read the release notes between your current and target versions, since major bumps occasionally change node behavior.
Most failures we see fall into a short list of avoidable errors. Knowing them up front saves hours.
- No explicit encryption key. The single most damaging mistake. Always set
N8N_ENCRYPTION_KEY. - Wrong
WEBHOOK_URL. Triggers from Slack, Stripe, GitHub, and similar services fail silently because callbacks go to the wrong address. - Using
localhost for the database host. Inside Compose, containers reach each other by service name (postgres), never localhost. - Forgetting named volumes. Without
n8n_data and postgres_data declared and mounted, a docker compose down followed by an image change can wipe your work. - Exposing port 5678 publicly with no auth or TLS. Put it behind a proxy and enable authentication.
- Running on SQLite in production. Fine for a demo, but it struggles with concurrent executions; the n8n docker-compose Postgres pattern above avoids this.
If you want to extend the instance further, our guide to connecting n8n to the Model Context Protocol shows how to wire AI agents into your self-hosted workflows, and our roundup of real n8n workflow examples gives you tested automations to import once the server is live.
Self-hosting with Compose is the right call when you want full data control, unlimited executions, and no per-step billing. It is the wrong call when nobody on the team owns server uptime, patching, and backups — an automation that silently dies at 2 a.m. costs more than the hosting it saved.
A reasonable middle path: self-host the platform, but bring in specialists to design the workflows and the deployment hardening. That is exactly the kind of work we do through our AI automation service — we stand up production n8n stacks, wire in monitoring, and ship working automations in around 14 days. If you are new to the tool entirely, start with our primer on what n8n is and how it works before committing to an architecture.
The honest trade-off is operational ownership. Compose removes the complexity of configuring n8n; it does not remove the responsibility of running it. Decide who owns that responsibility before you go live.
Self-hosting n8n with Docker Compose is mostly a one-time setup investment that pays off every day afterward. Once your docker-compose.yml is committed, your encryption key is stored safely, and your nightly backups are running, the platform becomes boring infrastructure — which is exactly what you want from automation that quietly runs your business. Get the Postgres backend, the encryption key, and the webhook URL right on day one, and the rest is just docker compose pull and docker compose up -d for years to come.