Hosto

Sign In

A Production-Grade n8n Docker Compose Setup (Postgres, HTTPS, Backups)

By Tushar Khatri

Monitor showing syntax-highlighted source code with line numbers

Most n8n Docker Compose tutorials stop at the quickstart: one container, SQLite, no HTTPS, no backup plan. That is fine for an afternoon of experimenting, but the moment a real workflow depends on your instance, you need a proper n8n production setup. This guide walks through a complete stack with three containers: n8n itself, PostgreSQL for the database, and Caddy for automatic HTTPS. You will also get a first-run checklist, a backup routine, and a sane upgrade process. If you are still deciding whether to self-host at all, start with our guide on how to self-host n8n and come back here when you are ready to do it properly.

Why Compose Plus Postgres Beats the Quickstart

The one-line docker run quickstart has three problems in production.

First, it defaults to SQLite. SQLite is a single file inside the container's data directory, and while it works, it does not handle concurrent executions as gracefully as Postgres, and it makes backups and restores more fragile. Postgres gives you pg_dump, point-in-time consistency, and a database you can inspect with standard tooling.

Second, there is no HTTPS. n8n handles credentials for your email, CRM, and payment providers. Serving the editor over plain HTTP on a public IP is not acceptable. Webhooks from third-party services also frequently require a valid HTTPS endpoint.

Third, nothing is written down. A Compose file is documentation that executes. When your server dies at 2 a.m., git clone, restore two backups, and docker compose up -d is a recovery plan. A half-remembered docker run command is not.

The Full n8n Docker Compose File

Here is the complete stack. Create a directory such as /opt/n8n, and save this as docker-compose.yml:

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER=n8n
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=n8n
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:1.99.0
    restart: unless-stopped
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=n8n
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=America/New_York
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config

volumes:
  postgres_data:
  n8n_data:
  caddy_data:
  caddy_config:

Next to it, create a .env file that Compose will read automatically:

POSTGRES_PASSWORD=use-a-long-random-password-here
N8N_ENCRYPTION_KEY=generate-with-openssl-rand-below

Generate the encryption key once:

openssl rand -hex 32

Now let's walk through each block.

The Postgres service

We pin postgres:16 rather than postgres:latest, because a surprise major-version jump on a database is a bad day. The credentials come from the .env file, and the data lives in the postgres_data named volume, so the database survives container recreation. The healthcheck matters more than it looks: it makes n8n wait until Postgres is actually accepting connections, not just until the container process starts, which avoids crash loops on boot.

The n8n service

The image is the official one, docker.n8n.io/n8nio/n8n, pinned to a specific version tag. Never run latest in production. Pinning means upgrades happen when you decide, after you have read the release notes, not whenever you happen to recreate a container.

The DB_* variables switch n8n from SQLite to Postgres. DB_TYPE=postgresdb is the master switch, and DB_POSTGRESDB_HOST=postgres points at the Postgres service by its Compose service name, which Docker resolves on the internal network.

The next group configures how n8n sees the outside world. N8N_HOST and N8N_PROTOCOL=https tell n8n its public identity, and WEBHOOK_URL=https://n8n.example.com/ is the single most important line for anyone running behind a reverse proxy. Without it, n8n builds webhook URLs from what it can see locally, which is http on port 5678, and every webhook you register with an external service will be wrong. If your Telegram or Stripe triggers never fire, this is the first thing to check. We cover the full diagnostic path in n8n webhook troubleshooting.

N8N_ENCRYPTION_KEY deserves its own paragraph. n8n encrypts every stored credential with this key. If you do not set it explicitly, n8n generates one and stores it in the data volume, which works until the day you restore a database backup without the matching key and discover that every credential is unreadable. Set it explicitly, and back it up somewhere outside the server: a password manager is perfect. Losing the database is recoverable. Losing the encryption key means re-entering every credential by hand.

Finally, the n8n_data volume maps to /home/node/.n8n, n8n's data directory. Even with Postgres holding workflows and credentials, this directory holds instance configuration and should always be persisted.

Notice what the n8n service does not have: a ports section. It is never exposed directly. Only Caddy listens on the host, and it reaches n8n over the private Compose network.

The Caddy service

Caddy publishes ports 80 and 443 and owns all TLS. The caddy_data volume stores the certificates it obtains, so you are not re-issuing certs on every restart.

The Caddyfile

This is the part people expect to be hard, and with Caddy it is genuinely two lines. Create a file named Caddyfile next to your Compose file:

n8n.example.com {
    reverse_proxy n8n:5678
}

That is the entire reverse proxy configuration. Because you gave Caddy a real domain name, it automatically obtains and renews a Let's Encrypt certificate, redirects HTTP to HTTPS, and proxies everything, including the WebSocket connections the n8n editor uses, to the n8n container on port 5678. Point your domain's A record at the server's IP before you start the stack, then bring everything up:

docker compose up -d

Give Caddy a few seconds to complete the certificate issuance, then load https://n8n.example.com in your browser.

First-Run Checklist

Before you build anything real, run through this list. It takes ten minutes and prevents the classic self-hosting disasters.

  1. Create the owner account immediately. The first visitor to a fresh n8n instance gets to create the owner account. Your instance is on the public internet the moment Caddy gets its certificate, so do this right away.
  2. Back up the encryption key now. Copy the N8N_ENCRYPTION_KEY value from your .env file into your password manager. Do it before you store a single credential, so it is impossible to forget.
  3. Verify HTTPS. Confirm the browser shows a valid certificate for your domain and that plain HTTP redirects to HTTPS.
  4. Test a webhook end to end. Create a workflow with a Webhook trigger, activate it, and confirm the production URL n8n displays starts with https://n8n.example.com/. Then hit it with curl from your laptop, not from the server, so you are testing the real public path.
  5. Reboot the server once. Run sudo reboot and confirm the whole stack comes back on its own. The restart: unless-stopped policies should handle it, but verify now rather than during a real outage.

The Backup and Upgrade Routine

A production instance needs three things backed up: the Postgres database, the n8n data volume, and the encryption key. The key is already in your password manager, so the script covers the first two:

#!/bin/bash
set -euo pipefail
BACKUP_DIR=/opt/n8n/backups
DATE=$(date +%F)
mkdir -p "$BACKUP_DIR"

# 1. Dump the database
docker compose -f /opt/n8n/docker-compose.yml exec -T postgres \
  pg_dump -U n8n n8n | gzip > "$BACKUP_DIR/n8n-db-$DATE.sql.gz"

# 2. Archive the n8n data volume
docker run --rm -v n8n_n8n_data:/data -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/n8n-data-$DATE.tar.gz" -C /data .

# 3. Keep 14 days
find "$BACKUP_DIR" -type f -mtime +14 -delete

Run it nightly from cron, and copy the resulting files off the server with rclone or scp. A backup that lives only on the machine it protects is a log file, not a backup.

As a supplement, the n8n CLI can export all workflows as JSON, which is handy for version control or migrating individual workflows:

docker compose exec n8n n8n export:workflow --all --output=/home/node/.n8n/workflows-export.json

Upgrades are simple because you pinned the version. The routine is: read the release notes for the new version, take a backup, then bump the tag and recreate the container.

# after editing the image tag in docker-compose.yml
docker compose pull n8n
docker compose up -d n8n

Your data survives because it lives in volumes and Postgres, not in the container. If the new version misbehaves, you can revert the tag, and if a database migration is involved, restore last night's dump.

When a Managed Dedicated VM Is the Better Trade

The stack above is genuinely production-grade, and on a small VPS it is cheap to run: Hetzner will host it for roughly 4 to 5 euros per month, DigitalOcean for about $6, with 2 GB of RAM as a comfortable minimum. What it costs instead is attention. You are now the person who reads release notes, tests restores, patches the OS, and answers the pager when a webhook stops firing.

For a lot of teams that trade makes sense. For others, especially once workflows start touching revenue, it does not. That is the gap managed n8n on Hosto is built for: you get a dedicated VM running your own n8n instance, with backups, monitoring, and updates handled for you, from $9/month billed annually. It is the same architecture described in this post, minus the operational overhead. If you would rather spend your hours building workflows than maintaining infrastructure, Hosto is the sensible version of this setup.

FAQ

Can I use SQLite instead of Postgres for production?

You can, and small single-user instances run on SQLite for years without trouble. But Postgres handles concurrent workflow executions better, gives you real backup tooling with pg_dump, and is what you will want in place before your instance matters. Since switching later means a migration, starting on Postgres costs you one extra Compose service and saves a painful weekend down the road.

What happens if I lose my N8N_ENCRYPTION_KEY?

Your workflows survive, but every stored credential becomes unreadable, because n8n encrypts credentials with that key. You would have to delete and re-enter every credential by hand. This is why the key belongs in your password manager from day one, backed up independently of the server and the database.

Why aren't my webhooks working behind the reverse proxy?

Nine times out of ten, WEBHOOK_URL is missing or wrong. It must be set to your full public HTTPS URL, such as https://n8n.example.com/, so n8n registers correct callback URLs with external services. Check the production URL shown on your Webhook trigger node: if it shows http://localhost:5678, that is the problem.

How much server do I need to run n8n?

For production use, 2 GB of RAM is a comfortable minimum for n8n, Postgres, and Caddy together. That maps to roughly a 4 to 5 euro per month Hetzner instance or a $6 per month DigitalOcean droplet. Heavy workloads with large payloads or many concurrent executions will want more, but start there and watch memory usage before scaling up.

Host it without the ops.

WordPress, WooCommerce, static sites, and dedicated n8n VMs. Same price at renewal, live in minutes.