What you’ll build: a production-ready, self-hosted n8n instance on a cheap VPS — Docker Compose, PostgreSQL storage, HTTPS via reverse proxy, automated backups, and unlimited workflow executions. Time: ~30–45 minutes. Cost: roughly $4–12/month for the server, $0 for n8n itself.
n8n Cloud’s starter plan costs around €24/month for 2,500 executions. A basic VPS costs a fraction of that and gives you unlimited executions, full data ownership, and no per-workflow anxiety. This is the setup I wish I’d had when I first self-hosted n8n — including the errors that cost the most time.
Should you self-host n8n at all?
Quick honesty check before you spend an evening on this. Self-hosting is the right call if any of these apply: you run (or plan to run) high execution volumes, you handle data you don’t want on someone else’s cloud, or you simply want to learn the ops side. Stay on n8n Cloud if you want zero maintenance and your volume is low — there’s no shame in paying for convenience. If you’re comparing platforms first, start with the tool reviews section.
Prerequisites
- A VPS with at least 2GB RAM (Hetzner, DigitalOcean, Linode, or similar — 1GB technically works but you’ll hit memory issues; see troubleshooting)
- A domain or subdomain (e.g.
n8n.yourdomain.com) with an A record pointing to your server’s IP - Basic comfort with SSH — you’ll copy-paste commands, nothing exotic
Step 1: Install Docker and the Compose plugin
SSH into your server (this guide assumes Ubuntu 24.04) and install Docker from the official repository — not the version in Ubuntu’s default repos, which lags behind:
🔴 🟡 🟢 terminal — ubuntu 24.04
bash
sudo apt update && sudo apt upgrade -y
sudo apt-get install ca-certificates curl -y
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
ARCH=$(dpkg --print-architecture)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
KEY=/etc/apt/keyrings/docker.asc
REPO=https://download.docker.com/linux/ubuntu
echo "deb [arch=$ARCH signed-by=$KEY] $REPO $CODENAME stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io \
docker-compose-plugin -y
Verify with docker --version and docker compose version — both should print version numbers with no errors before you continue.
Step 2: Create the Docker Compose stack (n8n + PostgreSQL)
n8n defaults to SQLite, which is fine for toying around. But if webhooks will hit your instance at any real volume, PostgreSQL handles concurrent writes far better and is much easier to back up. Set it up properly now and you’ll never migrate under pressure later.
🔴 🟡 🟢 terminal
bash
mkdir -p /opt/n8n && cd /opt/n8n
nano docker-compose.yml
Paste this, replacing the domain and passwords:
🔴 🟡 🟢 docker-compose.yml
yaml
services:
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=CHANGE_ME_STRONG_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: n8nio/n8n:latest
restart: always
ports:
- '127.0.0.1:5678:5678'
environment:
- N8N_HOST=n8n.yourdomain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://n8n.yourdomain.com/
- GENERIC_TIMEZONE=Asia/Kolkata
- N8N_ENCRYPTION_KEY=CHANGE_ME_LONG_RANDOM_STRING
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
volumes:
- n8n_data:/home/node/.n8n
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
n8n_data:
Three details that matter here. First, the port binds to 127.0.0.1 — n8n is only reachable from the server itself, and the reverse proxy in the next step handles public HTTPS access. Never expose 5678 directly to the internet. Second, WEBHOOK_URL must match your final public URL exactly, trailing slash included — this is the single most common self-hosting mistake (more in troubleshooting). Third, set N8N_ENCRYPTION_KEY yourself with something like openssl rand -hex 32 and save it in your password manager: it’s the only thing that can unlock your stored credentials, and losing it is unrecoverable.
Start the stack:
🔴 🟡 🟢 terminal
bash
docker compose up -d
docker compose logs -f n8n
Give it two or three minutes on first boot — you’re waiting for the log line saying the editor is now accessible on port 5678.
Step 3: HTTPS with Caddy (the easy reverse proxy)
You’ll see many guides use Nginx + Certbot. That works, but Caddy gets you automatic HTTPS in a few lines and renews certificates on its own — one less thing to break in six months. Install it from Caddy’s official repository:
🔴 🟡 🟢 terminal
bash
sudo apt install -y debian-keyring debian-archive-keyring \
apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor \
-o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf \
'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
If your server runs a firewall, open the web ports first (sudo ufw allow 80,443/tcp). Then point Caddy at n8n — replace the contents of /etc/caddy/Caddyfile with:
🔴 🟡 🟢 /etc/caddy/Caddyfile
caddy
n8n.yourdomain.com {
reverse_proxy 127.0.0.1:5678
}
Reload Caddy (sudo systemctl reload caddy), wait a minute for the certificate, and open https://n8n.yourdomain.com. You’ll be prompted to create the owner account — do this immediately, before anything else, because until you do, anyone who finds the URL can claim your instance.
Step 4: Backups (before you build anything you care about)
Everything lives in two places: the PostgreSQL database and the n8n_data volume (which holds your encryption key — lose it and your saved credentials are unrecoverable). A minimal nightly backup script:
🔴 🟡 🟢 /usr/local/bin/n8n-backup.sh
bash
#!/bin/bash
# /usr/local/bin/n8n-backup.sh
BACKUP_DIR=/opt/n8n/backups
COMPOSE=/opt/n8n/docker-compose.yml
STAMP=$(date +%F)
mkdir -p $BACKUP_DIR
docker compose -f $COMPOSE exec -T postgres \
pg_dump -U n8n n8n | gzip > $BACKUP_DIR/n8n-$STAMP.sql.gz
docker run --rm -v n8n_n8n_data:/data -v $BACKUP_DIR:/backup \
alpine tar czf /backup/n8n-data-$STAMP.tar.gz -C /data .
find $BACKUP_DIR -mtime +14 -delete
Make it executable and add it to cron (crontab -e, then 0 3 * * * /usr/local/bin/n8n-backup.sh). Fourteen days of rolling backups, deleted automatically.
Step 5: Updating n8n safely
n8n ships updates constantly, and database migrations are sometimes irreversible — so the safe pattern is: back up first, read the release notes for anything marked breaking, then:
🔴 🟡 🟢 terminal
bash
/usr/local/bin/n8n-backup.sh
cd /opt/n8n
docker compose pull
docker compose up -d
docker compose logs -f n8n
For production workloads, consider pinning a specific version tag instead of latest and upgrading deliberately. Workflows, credentials, and execution history all live in PostgreSQL and the data volume, so they survive container replacement.
Troubleshooting: the errors that actually happen
Webhooks return 404 or never fire
Almost always a wrong WEBHOOK_URL. It must be the exact public HTTPS URL, trailing slash included. Fix the environment variable, then docker compose up -d to recreate the container — a plain restart isn’t enough to pick up env changes in some setups.
Container keeps dying / out-of-memory on a 1GB VPS
Add a 2GB swap file as a band-aid, but honestly: if you’re running anything beyond hobby workflows, get a 2–4GB server. The cheapest tier costs you more in debugging time than it saves in rent.
Editor loads but shows connection/WebSocket errors
This is a reverse-proxy issue — the proxy isn’t forwarding WebSocket upgrade headers. Caddy handles this automatically (another reason I recommend it); if you chose Nginx, add the Upgrade and Connection headers to your proxy block.
Credentials could not be decrypted
Your encryption key changed — usually because the data volume was recreated, or you moved the database to a new server without bringing the key along. There’s a full recovery walkthrough in fixing the n8n encryption key error.
What to build first
You now have unlimited executions and nothing metered — the fun part starts. A great first real workflow is an email triage agent: genuinely useful from day one, and it teaches you triggers, AI nodes, and error handling in one build. More builds are in the AI Workflows and Automation Tutorials sections.
Hit an error this guide doesn’t cover? Tell me — include the step and the exact error message, and I’ll update the article and credit you.
Leave a comment