Self-host n8n with Docker Compose, Postgres and HTTPS

n8n is a workflow automation tool you can run on your own server. It's source-available under the Sustainable Use License, so self-hosting it for your own business is allowed. Read the licence page if you plan to resell it or embed it in a product.

author
Zahara Schaefer
published
read
7 min
tested
n8n 2.x (stable line 2.45 at time of writing), Docker Engine with the Compose plugin, Postgres 17, Caddy 2 (variables from the n8n hosting docs)
outline {
  1. What you need
  2. 1. Create the project folder and secrets
  3. 2. Write the Compose file
  4. 3. Add the Caddyfile
  5. 4. Start it and create the owner account
  6. 5. Test a webhook end to end
  7. 6. Back it up
  8. 7. Upgrade safely
  9. 8. When to move to queue mode
  10. Troubleshooting
  11. References

This guide sets up one n8n instance with:

  • Postgres as the database;
  • Caddy for automatic HTTPS;
  • a fixed encryption key;
  • backups;
  • an upgrade routine you can repeat.

At the end, I cover when to move to queue mode.

What you need

  • A Linux server or VM with Docker Engine and the Compose plugin. The Docker install docs cover each distribution. 2 vCPU and 4 GB RAM is a comfortable start for a single instance. That's my working assumption, not an n8n requirement.
  • A domain name, with an A record (for example n8n.example.com) pointing at the server.
  • Ports 80 and 443 open to the internet, so Caddy can get a certificate.
  • About 45 minutes.

Step 1: Create the project folder and secrets

bash
sudo mkdir -p /opt/n8n && cd /opt/n8n
sudo chown "$USER" /opt/n8n

Generate the two secrets you need: the Postgres password, and the n8n encryption key. n8n uses the encryption key to encrypt every credential you save. If you lose it, saved credentials can't be decrypted.

bash
cat > .env <<ENV
DOMAIN=n8n.example.com
POSTGRES_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
GENERIC_TIMEZONE=America/Chicago
ENV
chmod 600 .env

Hex values avoid characters that need quoting. n8n 2.0 changed how .env files are parsed: # now starts a comment, and backticks need quotes.

Copy the encryption key into your password manager now.

Check it worked: cat .env shows four lines with real values, and ls -l .env shows -rw-------.

Step 2: Write the Compose file

yaml/opt/n8n/compose.yaml
# /opt/n8n/compose.yaml
services:
  postgres:
    image: postgres:17
    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: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:2.45.7
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    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_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_HOST: ${DOMAIN}
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      N8N_WEBHOOK_URL: https://${DOMAIN}/
      N8N_PROXY_HOPS: 1
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      NODE_ENV: production
      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${GENERIC_TIMEZONE}
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: 336
    volumes:
      - n8n_data:/home/node/.n8n

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

volumes:
  postgres_data:
  n8n_data:
  caddy_data:
  caddy_config:

Notes on the choices:

  • Pin the image tag. Use the current stable version from the n8n release notes, not latest, so upgrades happen when you decide. 2.45.7 was the stable release when I wrote this.
  • `N8N_WEBHOOK_URL` is the public URL n8n shows for webhook nodes. It replaced WEBHOOK_URL in n8n 2.30.0. On older versions, use WEBHOOK_URL.
  • `N8N_PROXY_HOPS: 1` tells n8n it sits behind one reverse proxy, so it trusts the forwarded headers from Caddy.
  • `EXECUTIONS_DATA_MAX_AGE` is in hours. 336 hours keeps 14 days of execution history. Pick what you need for debugging.
  • n8n 2.x is stricter by default. Code nodes can't read environment variables (N8N_BLOCK_ENV_ACCESS_IN_NODE defaults to true). The Execute Command and Local File Trigger nodes are disabled. File access is limited to one folder. Leave these defaults alone unless a workflow truly needs more, and read the v2 breaking changes before you change them.

Only Caddy publishes ports. n8n and Postgres are reachable only on the Compose network.

Check it worked: docker compose config prints the file with your values filled in and no errors.

Step 3: Add the Caddyfile

text/opt/n8n/Caddyfile
# /opt/n8n/Caddyfile
{$DOMAIN:n8n.example.com} {
	reverse_proxy n8n:5678
}

Replace the domain, or pass DOMAIN to the caddy service as an environment variable. Caddy gets and renews a Let's Encrypt certificate automatically. It also passes through the WebSocket connections the n8n editor uses.

Check it worked: dig +short n8n.example.com returns your server's address before you start the stack. Caddy can't get a certificate until DNS resolves.

Step 4: Start it and create the owner account

bash
docker compose up -d
docker compose logs -f n8n

Wait for Editor is now accessible via: in the log, then open https://n8n.example.com. The first visit asks you to create the owner account. Use a strong password, then turn on two-factor authentication under Settings > Personal.

Check it worked: the editor loads over HTTPS with a valid certificate, and docker compose ps shows all three services as running (Postgres as healthy).

Step 5: Test a webhook end to end

Create a workflow with a Webhook node (method POST, path hello) followed by a Respond to Webhook node. Click Listen for test event, then:

bash
curl -X POST https://n8n.example.com/webhook-test/hello -H 'Content-Type: application/json' -d '{"ping":1}'

Activate the workflow (the Active toggle), then call the production URL:

bash
curl -X POST https://n8n.example.com/webhook/hello -d '{"ping":2}' -H 'Content-Type: application/json'

Check it worked: both calls return a response, and the production call appears under Executions. If the URLs shown in the Webhook node start with http://localhost, N8N_WEBHOOK_URL isn't set correctly.

Step 6: Back it up

Three things matter:

  1. The Postgres database: workflows, credentials (encrypted), users and execution history.
  2. The encryption key: in .env, and in your password manager.
  3. The `n8n_data` volume: it holds config and, by default, binary data from executions.

A nightly dump:

bash
sudo tee /usr/local/sbin/n8n-backup >/dev/null <<'SH'
#!/bin/sh
set -eu
cd /opt/n8n
mkdir -p /var/backups/n8n
docker compose exec -T postgres pg_dump -U n8n -d n8n -Fc > /var/backups/n8n/n8n-$(date +%F).dump
find /var/backups/n8n -name '*.dump' -mtime +14 -delete
SH
sudo chmod 700 /usr/local/sbin/n8n-backup

Run it from a systemd timer or cron, and get /var/backups/n8n off the server. The 3-2-1-1-0 guide shows one way to do that with restic.

As a second copy that's easy to read and diff, export the workflows as JSON:

bash
docker compose exec -u node n8n n8n export:workflow --all --separate --output=/home/node/.n8n/exports/

Check it worked: restore the latest dump into a throwaway Postgres container, and confirm the workflow_entity table has rows:

bash
docker run --rm -d --name pgtest -e POSTGRES_PASSWORD=test postgres:17
sleep 5
docker exec -i pgtest pg_restore -U postgres -d postgres --no-owner < /var/backups/n8n/n8n-$(date +%F).dump
docker exec pgtest psql -U postgres -c 'select count(*) from workflow_entity;'
docker rm -f pgtest

Step 7: Upgrade safely

  1. Read the release notes for every version between yours and the target. Look for breaking changes.
  2. Take a fresh dump (step 6).
  3. Change the image tag in compose.yaml.
  4. Pull and restart:
bash
docker compose pull n8n
docker compose up -d n8n
docker compose logs -f n8n

n8n runs any database migrations on startup. To roll back, put the old tag back and restore the dump taken before the upgrade. A newer schema doesn't always work with an older n8n.

Check it worked: Settings > About n8n shows the new version, and a test webhook from step 5 still runs.

Step 8: When to move to queue mode

A single instance runs everything in one process. When executions start queuing up, or one heavy workflow slows down the editor, n8n's queue mode splits the work:

  • the main instance handles the editor, triggers and webhooks;
  • Redis holds a queue of executions;
  • one or more workers (n8n worker --concurrency=5) run them.

In outline, you add a Redis service and set EXECUTIONS_MODE=queue and QUEUE_BULL_REDIS_HOST on every n8n process. Every process also needs the same N8N_ENCRYPTION_KEY and database settings. Things to know first:

  • Queue mode needs Postgres. The docs don't recommend SQLite with it.
  • Binary data stored on the file system isn't supported in queue mode. Use S3-compatible storage instead.
  • Running several main instances (multi-main) needs an Enterprise licence.

Troubleshooting

SymptomLikely causeFix
Caddy logs certificate errorsDNS not pointing at the server, or port 80 blockedFix DNS or firewall, then docker compose restart caddy.
Webhook URLs show localhostN8N_WEBHOOK_URL missing, or the variable name doesn't match your versionSet it (or WEBHOOK_URL before 2.30.0) and restart.
"Credentials could not be decrypted"Encryption key changed or missingRestore the original N8N_ENCRYPTION_KEY.
A Code node can't read $envn8n 2.x blocks env access by defaultPass the value another way, for example a credential or a Set node. Only change N8N_BLOCK_ENV_ACCESS_IN_NODE if you understand the risk.
Database grows without stoppingExecution pruning offSet EXECUTIONS_DATA_PRUNE and EXECUTIONS_DATA_MAX_AGE.

References