A self-hosted n8n that survives contact with production needs three containers, not one: n8n, PostgreSQL, and a reverse proxy that terminates TLS.
Everything below was run on a fresh Ubuntu 24.04 LTS VPS with 2 vCPU and 4 GB RAM.
What you will end up with
n8n on https://n8n.example.com, with a certificate issued and renewed
automatically, workflow data in PostgreSQL, and port 5678 reachable only from
inside the Compose network.
This guide does not cover queue mode with Redis workers, which you need once a single n8n process can no longer keep up with concurrent executions. It also does not cover n8n’s enterprise features.
Requirements
- Ubuntu 24.04 LTS, 2 GB RAM minimum. n8n plus Postgres idles around 700 MB.
- A domain with an A record pointing at the server’s public IPv4 address. Create it now — Caddy needs it resolving before it can get a certificate.
- Ports 80 and 443 open. Port 80 is required for the ACME HTTP challenge even though all traffic ends up on 443.
Verify DNS has propagated before you start:
dig +short n8n.example.com203.0.113.42If that returns nothing, stop and fix DNS. Caddy will otherwise fail its certificate request and retry with a backoff that gets slow quickly.
Install Docker and prepare the directory
Use Docker’s own apt repository. The docker.io package in Ubuntu’s archive
lags and does not include the Compose v2 plugin. Docker’s convenience script
configures that repository for you — download it and read it before running it
as root.
curl -fsSL https://get.docker.com -o get-docker.sh
less get-docker.sh # confirm what it will do before granting root
sudo sh get-docker.sh
sudo usermod -aG docker "$USER"
newgrp docker
docker compose versionDocker Compose version v2.29.7Create the stack directory:
- /opt/n8n/
- compose.yaml
- Caddyfile
- .env
sudo mkdir -p /opt/n8n
sudo chown "$USER":"$USER" /opt/n8n
cd /opt/n8nWrite the Compose file
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:1.60
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
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_HOST: ${N8N_HOST}
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: https://${N8N_HOST}/
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
N8N_RUNNERS_ENABLED: "true"
volumes:
- n8n_data:/home/node/.n8n
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- n8n
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:Three details in there are load-bearing.
The n8n service publishes no ports. Caddy reaches it as n8n:5678 over the
Compose network. If you publish 5678 you have also published an unauthenticated
setup page to the internet for however long it takes you to create the owner
account.
$${POSTGRES_USER} in the healthcheck is escaped so Compose passes a literal
${POSTGRES_USER} into the container’s shell rather than substituting it at
parse time.
caddy_data must be a named volume. It holds the issued certificates and the
ACME account key; losing it means re-issuing from scratch and burning Let’s
Encrypt rate limit.
The request path is worth holding in your head, because every failure below maps
onto one hop of it. A browser resolves the hostname to the server, Caddy accepts
the TLS connection on 443, proxies plain HTTP to n8n:5678 over the Compose
network, and n8n reads and writes workflow state in Postgres over the same
network. Only Caddy publishes ports; the other two services exist solely on that
internal network.
Compose creates a default network named after the project directory and attaches
every service to it, which is why postgres and n8n resolve as hostnames
without any explicit network configuration.
Caddyfile and environment
The Caddyfile is two lines. Caddy requests and renews the certificate on its own.
n8n.example.com {
reverse_proxy n8n:5678
}Generate a real encryption key rather than typing one:
openssl rand -base64 32N8N_HOST=n8n.example.com
GENERIC_TIMEZONE=Europe/Berlin
POSTGRES_USER=n8n
POSTGRES_PASSWORD=change-me-to-a-long-random-string
POSTGRES_DB=n8n
N8N_ENCRYPTION_KEY=paste-the-openssl-output-herechmod 600 /opt/n8n/.envStart the stack and create the owner account
cd /opt/n8n
docker compose up -d
docker compose psNAME IMAGE STATUS
n8n-caddy-1 caddy:2-alpine Up 24 seconds
n8n-n8n-1 docker.n8n.io/n8nio/n8n:1.60 Up 24 seconds
n8n-postgres-1 postgres:17-alpine Up 34 seconds (healthy)Watch Caddy get its certificate:
docker compose logs -f caddyLook for certificate obtained successfully. Then open
https://n8n.example.com and create the owner account immediately. Until
you do, anyone who reaches that URL can claim it.
n8n replaced the old N8N_BASIC_AUTH_* variables with built-in user
management. If a tutorial tells you to set them, it predates that change.
Verify the deployment
n8n exposes a health endpoint that does not require authentication:
curl -fsS https://n8n.example.com/healthz{"status":"ok"}Confirm the database is actually being used — an empty output here means n8n silently fell back to SQLite:
docker compose exec postgres psql -U n8n -d n8n -c '\dt' | headYou should see tables including workflow_entity, credentials_entity and
execution_entity.
Finally, confirm 5678 is not exposed. From your laptop:
curl --max-time 5 http://203.0.113.42:5678/That must time out or be refused.
Common errors
getaddrinfo ENOTFOUND postgres — n8n started before Postgres, or the
service name does not match DB_POSTGRESDB_HOST. The condition: service_healthy dependency above handles the first case; check spelling for
the second.
password authentication failed for user "n8n" — you changed
POSTGRES_PASSWORD after the volume was created. Postgres only reads
POSTGRES_PASSWORD when it initialises an empty data directory. Either set the
password inside the database with ALTER ROLE, or destroy the volume and start
over.
Mismatching encryption keys in the n8n logs, or “Credentials could not be
decrypted” in the UI — N8N_ENCRYPTION_KEY differs from the value used when
the credentials were saved. n8n also persists a copy at
/home/node/.n8n/config; if the environment variable and that file disagree,
n8n refuses to start.
Caddy logs obtaining certificate ... connection refused — port 80 is
blocked. Check sudo ufw status, and check whether your provider has a
separate cloud firewall in front of the instance.
Webhook URLs contain localhost:5678 — WEBHOOK_URL is missing. Set it to
the full public URL with a trailing slash and restart n8n. Existing workflows
pick up the new URL, but any webhook you already registered with a third party
has to be updated there too.
EACCES: permission denied, open '/home/node/.n8n/config' — you replaced
the named volume with a host bind mount. The n8n image runs as UID 1000; run
sudo chown -R 1000:1000 /your/host/path.
Backups, upgrades and security notes
A database dump plus the encryption key is a complete backup. Run it nightly:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n
stamp=$(date +%F)
docker compose exec -T postgres pg_dump -U n8n -Fc n8n > "/var/backups/n8n-${stamp}.dump"
find /var/backups -name 'n8n-*.dump' -mtime +14 -delete-Fc produces the custom format, which pg_restore can read selectively. Test
a restore into a scratch database at least once — an untested backup is a
hypothesis.
Firewall rules: allow 22, 80 and 443 and nothing else.
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enablePin the n8n image to a minor tag rather than latest. n8n runs schema
migrations on start, and an unattended latest pull during a docker compose up -d can migrate the database out from under a version you have not tested.
If your workflows execute arbitrary JavaScript through the Code node, treat the n8n container as a code-execution surface. Keep it off any network segment that can reach your internal systems unless a workflow genuinely needs to.