Production-Ready Docker Compose Setup for n8n

Ahmed
0

Production-Ready Docker Compose Setup for n8n

I’ve shipped n8n stacks where one tiny detail—like a missing encryption key or a wrong webhook URL—quietly broke automations days later.


Production-Ready Docker Compose Setup for n8n gives you a clean, repeatable stack with persistent data, correct webhooks, and HTTPS that survives real production updates.


Production-Ready Docker Compose Setup for n8n

What “production-ready” means for an n8n stack

“Production-ready” isn’t about fancy infrastructure—it’s about preventing the predictable failures that cost you hours:

  • Persistent state: workflows, credentials, and executions survive container restarts.
  • Stable encryption: credentials remain decryptable after redeploys.
  • Correct webhooks: external services always hit your real HTTPS endpoint, not localhost.
  • Controlled exposure: only what must be public is public.
  • Safe operations: backups and updates are simple, boring, and repeatable.

Core components in this setup

This stack uses a small set of proven building blocks:

  • n8n for workflow automation.
  • Postgres for durable, production-grade persistence.
  • Traefik as a reverse proxy for HTTPS and routing.
  • Docker Compose to define and run the stack consistently.

Real-world weakness to plan for (n8n): if you let n8n auto-generate its encryption key and later redeploy on a fresh host or wipe the user folder, previously saved credentials may become unreadable. Fix: set and keep a single N8N_ENCRYPTION_KEY permanently, and back it up like a database password.


Real-world weakness to plan for (Postgres): the container can restart fine while the disk is full or the volume is unhealthy—your automations “run” but fail at persistence. Fix: put Postgres on a real persistent volume, monitor disk space, and test restores.


Real-world weakness to plan for (Traefik): misconfigured ACME/Let’s Encrypt or closed inbound ports leads to broken HTTPS renewals at the worst time. Fix: confirm ports 80/443, verify the resolver, and keep ACME storage persistent.


Real-world weakness to plan for (Docker Compose): it’s not a cluster manager—host failure is total failure. Fix: mitigate with backups, infrastructure snapshots, and a documented restore plan (and move to orchestration only when you truly need it).


Recommended directory layout

Create a dedicated folder so upgrades and backups are predictable:

  • ./n8n/ (project root)
  • ./n8n/data/ (n8n user folder if you choose to persist it)
  • ./n8n/traefik/ (Traefik config + ACME storage)
mkdir -p n8n/traefik

mkdir -p n8n/data cd n8n # Create an empty ACME storage file for Traefik and lock down permissions touch traefik/acme.json
chmod 600 traefik/acme.json

Create your .env file (the “single source of truth”)

Keep environment values out of your Compose file. It reduces mistakes and makes updates safer.


Critical: use a strong, random encryption key and keep it forever. If you rotate it without a migration plan, saved credentials can break.

# Public URL (use a dedicated subdomain)

N8N_DOMAIN=n8n.example.com # Used by n8n to generate correct URLs in the UI and for webhook registrations WEBHOOK_URL=https://n8n.example.com/ # n8n hardening essentials N8N_ENCRYPTION_KEY=REPLACE_WITH_A_LONG_RANDOM_VALUE N8N_PROXY_HOPS=1 N8N_LOG_LEVEL=info GENERIC_TIMEZONE=America/New_York # Postgres (choose strong values) POSTGRES_DB=n8n POSTGRES_USER=n8n POSTGRES_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_PASSWORD # Traefik ACME email (used for certificate lifecycle notifications)
LETSENCRYPT_EMAIL=admin@example.com

Docker Compose file (n8n + Postgres + Traefik)

This Compose file keeps it simple: Traefik terminates HTTPS, n8n stays internal, Postgres persists on a named volume.


Item Why it matters
Named volumes Prevents accidental data loss during container recreation.
Internal network Keeps Postgres off the public internet.
WEBHOOK_URL Stops “localhost” webhooks and fixes external registrations.
N8N_ENCRYPTION_KEY Makes credential encryption stable across upgrades and restores.
services:

traefik: image: traefik:v3.5 container_name: traefik restart: unless-stopped command: - "--api.dashboard=false" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL}" - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.le.acme.httpchallenge=true" - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" ports: - "80:80" - "443:443" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "letsencrypt:/letsencrypt" - "./traefik/acme.json:/letsencrypt/acme.json" networks: - edge postgres: image: postgres:16 container_name: n8n-postgres restart: unless-stopped environment: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data networks: - internal n8n: image: n8nio/n8n:latest container_name: n8n restart: unless-stopped depends_on: - postgres environment: - DB_TYPE=postgresdb - DB_POSTGRESDB_HOST=postgres - DB_POSTGRESDB_PORT=5432 - DB_POSTGRESDB_DATABASE=${POSTGRES_DB} - DB_POSTGRESDB_USER=${POSTGRES_USER} - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD} - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_PROXY_HOPS=${N8N_PROXY_HOPS} - WEBHOOK_URL=${WEBHOOK_URL} - GENERIC_TIMEZONE=${GENERIC_TIMEZONE} - N8N_LOG_LEVEL=${N8N_LOG_LEVEL} volumes: - n8n_data:/home/node/.n8n networks: - edge - internal labels: - "traefik.enable=true" - "traefik.http.routers.n8n.rule=Host(`${N8N_DOMAIN}`)" - "traefik.http.routers.n8n.entrypoints=websecure" - "traefik.http.routers.n8n.tls=true" - "traefik.http.routers.n8n.tls.certresolver=le" - "traefik.http.services.n8n.loadbalancer.server.port=5678" networks: edge: name: edge internal: name: internal volumes: postgres_data: n8n_data:
letsencrypt:

Bring the stack up safely

Start it in a way that makes troubleshooting obvious.

docker compose pull

docker compose up -d # Watch logs during first boot (especially Traefik ACME + n8n startup)
docker compose logs -f --tail=200

Common pitfall: you can open the editor in your browser, but webhook nodes still show “localhost.” That almost always traces back to a missing/incorrect WEBHOOK_URL or proxy headers. Keep WEBHOOK_URL set to your real HTTPS domain and keep proxy hops correct.


Hardening that actually matters (without slowing you down)

  • Keep Postgres private: don’t publish its port to the host.
  • Persist ACME storage: losing ACME state can cause rate limits or renewal churn.
  • Use strong secrets: treat the encryption key like a master key, and store it in a secure password manager.
  • Limit admin exposure: if you don’t need n8n reachable from everywhere, restrict inbound access at the firewall or via your edge controls.

Backups you can trust (and restores you’ve actually tested)

The only backup that counts is one you restored successfully. Do two things:

  • Database dumps on a schedule (and copy them off-host).
  • Secrets preservation (your N8N_ENCRYPTION_KEY must match the restored data).
# Create a timestamped Postgres dump inside the container

docker exec -t n8n-postgres pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" > n8n_pg_$(date +%F_%H%M).sql # Also back up your .env securely (it contains the encryption key)
# Store it in a secure vault/password manager, not in a public repo.

Restore reality check: a Postgres dump without the original encryption key will restore workflows, but credentials may fail to decrypt. Keep the key stable across the lifecycle.


Updates without breaking production

Most production outages during upgrades come from rushing. Keep a simple routine:

  1. Take a fresh Postgres dump.
  2. Pull images.
  3. Recreate containers.
  4. Verify editor access, then trigger one real webhook test.
docker compose pull

docker compose up -d # If you want to confirm what changed: docker compose ps
docker image ls | head

Operational troubleshooting (fast, not theoretical)

  • HTTPS not issuing: confirm ports 80/443 inbound, confirm DNS points to the host, then inspect Traefik logs.
  • Webhooks show localhost: re-check WEBHOOK_URL and proxy hop settings; then restart n8n.
  • Credentials suddenly fail: verify N8N_ENCRYPTION_KEY didn’t change and your n8n data volume is intact.
  • Random workflow failures under load: check host memory and disk; container restarts often look like “intermittent” automation bugs.

Official references worth bookmarking once

Use these official pages as your source of truth when you need to verify a setting:


Advanced FAQ (real questions that show up in production)

Why does n8n still display “localhost” for webhooks even though the site loads on HTTPS?

That usually happens when n8n can’t infer the external URL from proxy headers, so it falls back to a local default. Keep WEBHOOK_URL set to your public HTTPS URL and keep proxy hop handling consistent so n8n reliably generates the right endpoint.


Do you really need Postgres, or is the default database enough?

If you care about predictable persistence and safer operations, Postgres is the practical choice. It’s easier to back up reliably, easier to move between hosts, and less fragile under real write patterns than a “works on my server” default.


What’s the safest way to store N8N_ENCRYPTION_KEY?

Treat it like a master key: store it in a secure secrets vault/password manager, restrict access to only the people who run production, and make sure your disaster recovery process includes it. If that key changes unexpectedly, credentials can stop working after a restore or redeploy.


Should you expose the n8n editor to the public internet?

Only if you must. Many production stacks keep inbound access restricted at the edge so only webhooks are broadly reachable, while the editor is limited to trusted access paths. You’ll reduce brute-force noise and lower your operational risk.


What breaks most often during “simple updates”?

The top causes are missed backups, accidental changes to environment variables, and losing persistent storage (named volumes or the ACME file). A routine that always backs up first and keeps secrets stable prevents most painful surprises.


How do you verify this setup is truly working before connecting real services?

Run one controlled test webhook from a disposable workflow, confirm it hits your HTTPS domain, then restart the stack and confirm the workflow and credentials still behave the same way. If that passes, you’ve proven persistence, routing, and encryption continuity.



Conclusion

If you keep encryption stable, force correct webhook URLs, and make persistence non-negotiable, your n8n instance stops feeling like a “project” and starts behaving like production infrastructure. Put this Compose stack under disciplined backups, update it calmly, and you’ll avoid the failure patterns that derail automation teams.


Post a Comment

0 Comments

Post a Comment (0)