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.
outline {
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
sudo mkdir -p /opt/n8n && cd /opt/n8n
sudo chown "$USER" /opt/n8nGenerate 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.
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 .envHex 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
# /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_URLin n8n 2.30.0. On older versions, useWEBHOOK_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_NODEdefaults 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
# /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
docker compose up -d
docker compose logs -f n8nWait 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:
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:
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:
- The Postgres database: workflows, credentials (encrypted), users and execution history.
- The encryption key: in
.env, and in your password manager. - The `n8n_data` volume: it holds config and, by default, binary data from executions.
A nightly dump:
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-backupRun 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:
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:
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 pgtestStep 7: Upgrade safely
- Read the release notes for every version between yours and the target. Look for breaking changes.
- Take a fresh dump (step 6).
- Change the image tag in
compose.yaml. - Pull and restart:
docker compose pull n8n
docker compose up -d n8n
docker compose logs -f n8nn8n 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Caddy logs certificate errors | DNS not pointing at the server, or port 80 blocked | Fix DNS or firewall, then docker compose restart caddy. |
Webhook URLs show localhost | N8N_WEBHOOK_URL missing, or the variable name doesn't match your version | Set it (or WEBHOOK_URL before 2.30.0) and restart. |
| "Credentials could not be decrypted" | Encryption key changed or missing | Restore the original N8N_ENCRYPTION_KEY. |
A Code node can't read $env | n8n 2.x blocks env access by default | Pass 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 stopping | Execution pruning off | Set EXECUTIONS_DATA_PRUNE and EXECUTIONS_DATA_MAX_AGE. |
References
- n8n Docker Compose setup: https://docs.n8n.io/hosting/installation/server-setups/docker-compose/
- n8n environment variables: https://docs.n8n.io/hosting/configuration/environment-variables/
- n8n 2.0 breaking changes: https://docs.n8n.io/changelog/v20-breaking-changes
- n8n queue mode: https://docs.n8n.io/hosting/scaling/queue-mode/
- n8n CLI commands (export): https://docs.n8n.io/hosting/cli-commands/
- Sustainable Use License: https://docs.n8n.io/sustainable-use-license/
- Caddy reverse proxy: https://caddyserver.com/docs/quick-starts/reverse-proxy