Self-Hosting n8n with Docker: The Complete Setup Guide (2026)

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.

One tested workflow, weekly.

Get builds like this one in your inbox. No hype, no spam.

Leave a comment