n8n Self-Hosted on a VPS: Docker Compose and HTTPS via Caddy
To self-host n8n, run it on a VPS with Docker Compose next to PostgreSQL and put Caddy in front: Caddy gets a Let's Encrypt certificate by itself, so webhooks and the editor work over HTTPS from the first start. The whole setup takes about 15 minutes on Ubuntu 24.04 or Debian 12.
What you need
- A VPS. Pulse (2 vCPU, 2 GB RAM, 15 GB NVMe, $10/month) is the comfortable minimum for n8n with PostgreSQL. If you plan heavy workflows that process big JSON or files, take Drive.
- A domain or subdomain (for example
n8n.example.com) with an A record pointing to the server's IP. Caddy needs it to issue the certificate. - Root access over SSH (see Connecting over SSH) and Docker installed (see Docker on a VPS).
1. Firewall and Docker
apt update && apt install -y ufw
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw allow 443/udp
ufw enable
curl -fsSL https://get.docker.com | sh
Only Caddy publishes ports. n8n and PostgreSQL stay inside the Docker network, because Docker's published ports bypass ufw.
2. Secrets
mkdir -p /opt/n8n && cd /opt/n8n
cat > .env <<EOF
DOMAIN=n8n.example.com
DB_PASSWORD=$(openssl rand -hex 16)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
EOF
chmod 600 .env
n8n encrypts stored credentials with N8N_ENCRYPTION_KEY. Lose it and every saved credential becomes unreadable, so back up .env together with the database.
3. docker-compose.yml
/opt/n8n/docker-compose.yml:
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: n8n
volumes:
- db:/var/lib/postgresql/data
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
depends_on: [db]
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: db
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${DB_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: ${DOMAIN}
N8N_PROTOCOL: https
WEBHOOK_URL: https://${DOMAIN}/
N8N_PROXY_HOPS: 1
GENERIC_TIMEZONE: Europe/Berlin
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 168
volumes:
- n8n:/home/node/.n8n
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
volumes:
db:
n8n:
caddy_data:
Set GENERIC_TIMEZONE to your own zone: Cron and Schedule nodes use it.
4. Caddyfile
/opt/n8n/Caddyfile (replace the domain):
n8n.example.com {
reverse_proxy n8n:5678
}
Caddy proxies WebSockets out of the box, so the editor's live updates work without extra settings.
5. Start
cd /opt/n8n
docker compose up -d
docker compose logs -f caddy n8n
Open https://n8n.example.com and create the owner account right away. Until you do, anyone who opens the page first can claim the instance.
Common mistakes
- Webhook URLs show
localhost:5678.WEBHOOK_URLis missing or wrong. Fix it in the compose file and rundocker compose up -d. - No certificate. The A record doesn't point to the server yet, or ports 80/443 are closed.
docker compose logs caddyshows the exact reason. - The disk fills up. Execution history lives in PostgreSQL. With 15 GB, keep pruning on (as above) and don't store binary files in executions.
- Updates.
docker compose pull && docker compose up -d. Read the n8n release notes before major versions. - Backups are on you.
docker compose exec -T db pg_dump -U n8n n8n > n8n.sqlplus the.envfile, copied off the server on a schedule.
Want files and calendars on the same kind of setup? See Nextcloud on a VPS.