Skip to content
Cloud & Infra Beginner Tutorial

Self-Host n8n on a VPS with Docker Compose and Caddy

Run your own n8n 2.x with automatic HTTPS, persistent volumes, and isolated task runners on one Ubuntu VPS.

Emeka Okafor
Emeka Okafor
Security Editor · Aug 29, 2026 · 6 min read
Self-Host n8n on a VPS with Docker Compose and Caddy

1. What you'll build

A single-VPS n8n instance behind Caddy, reachable at https://n8n.yourdomain.com with a Let's Encrypt certificate Caddy obtains and renews on its own. Workflows, credentials, and the TLS state live in Docker volumes, so docker compose down and back up loses nothing.

2. Prerequisites

Verified against n8n 2.36.8, Caddy 2.11.4, Docker Engine 29.7.2, and Docker Compose v5.5.0 on Ubuntu 24.04 LTS (August 29, 2026).

  • A VPS with 2 GB RAM, a public IPv4, and root or sudo SSH access. Ubuntu 22.04 and 26.04 also work with the same commands.
  • A domain you control. You'll add one A record.
  • Ports 80 and 443 free on the host. If Apache or nginx is already listening, stop it first, or the Caddy container won't start.

3. Point DNS at the server

Create an A record n8n pointing at the VPS IP. Confirm it resolves before starting containers; Caddy requests a certificate at boot and the challenge fails if DNS points elsewhere:

dig +short n8n.example.com

You should see the server IP and nothing else.

4. Install Docker

Docker's convenience script installs Engine plus the Compose v2 plugin in one go:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker "$USER"
newgrp docker
docker compose version

The last line prints Docker Compose version v5.5.0 or newer. If you'd rather use the apt repository, the Ubuntu install page has the equivalent steps.

5. Open the firewall

Ubuntu ships ufw but leaves it off. Allow SSH first so you don't lock yourself out:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable

Port 80 stays open on purpose: Let's Encrypt validates over HTTP, and Caddy redirects everything else to HTTPS. The UDP rule enables HTTP/3.

6. Create the project and .env

mkdir -p ~/n8n && cd ~/n8n
cat > .env <<ENV
N8N_DOMAIN=n8n.example.com
N8N_VERSION=2.36.8
GENERIC_TIMEZONE=Europe/Berlin
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
RUNNERS_AUTH_TOKEN=$(openssl rand -hex 32)
ENV
chmod 600 .env
cat .env

Replace the domain and timezone. N8N_ENCRYPTION_KEY encrypts stored credentials; n8n would generate one for you, but pinning it in .env means you can restore the database volume onto a fresh host and still decrypt everything. Back this file up.

7. Write compose.yaml

n8n 2.x runs Code nodes in task runners. The docs call the built-in internal mode "insecure by design" for production, so this file uses the separate n8nio/runners sidecar, which is also what n8n's own Caddy reference setup does.

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      - N8N_DOMAIN=${N8N_DOMAIN}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

  n8n:
    image: n8nio/n8n:${N8N_VERSION}
    restart: unless-stopped
    environment:
      - N8N_HOST=${N8N_DOMAIN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_EDITOR_BASE_URL=https://${N8N_DOMAIN}/
      - N8N_WEBHOOK_URL=https://${N8N_DOMAIN}/
      - N8N_PROXY_HOPS=1
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
    volumes:
      - n8n_data:/home/node/.n8n

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: unless-stopped
    environment:
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
    depends_on:
      - n8n

volumes:
  caddy_data:
  caddy_config:
  n8n_data:

The n8n service publishes no ports; Caddy reaches it over the Compose network as n8n:5678, so 5678 is never exposed to the internet. N8N_PROXY_HOPS=1 tells n8n to trust the X-Forwarded-* headers Caddy adds, which is what makes webhook and OAuth callback URLs come out as https://n8n.example.com/... instead of http://localhost:5678/....

8. Write the Caddyfile

{$N8N_DOMAIN} {
    reverse_proxy n8n:5678 {
        flush_interval -1
    }
}

That's the whole reverse proxy config. Naming a public hostname in a site block turns on automatic HTTPS. flush_interval -1 disables response buffering so n8n's streamed execution updates reach the browser as they happen.

9. Start it

docker compose up -d
docker compose logs -f caddy n8n

Watch the log until the certificate lands (seconds on a clean run), then Ctrl-C.

10. Verify it works

Three containers should be up:

docker compose ps --format "table {{.Service}}\t{{.Status}}"
SERVICE      STATUS
caddy        Up 2 minutes
n8n          Up 2 minutes
n8n-runner   Up 2 minutes

Caddy's log should contain a line like this, with your domain in it:

{"level":"info","logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"n8n.example.com"}

And n8n's log ends with:

n8n ready on 0.0.0.0, port 5678
Editor is now accessible via:
https://n8n.example.com

From your laptop, confirm TLS terminates correctly:

curl -sI https://n8n.example.com/ | head -1
HTTP/2 200

Open https://n8n.example.com in a browser. You'll get n8n's owner signup screen. The password needs at least eight characters with one number and one capital letter. After that you're in the editor, and any Webhook node you add will show a URL on your domain.

11. Troubleshooting

Caddy logs could not get certificate from issuer with urn:ietf:params:acme:error:connection. Let's Encrypt couldn't reach http://n8n.example.com/.well-known/acme-challenge/.... Either the A record hasn't propagated (recheck with dig) or port 80 is blocked by ufw or your provider's cloud firewall. Fix the cause, then docker compose restart caddy rather than waiting out Caddy's retry backoff.

Bind for 0.0.0.0:80 failed: port is already allocated. Something else on the host owns port 80, usually a leftover Apache or nginx. Find it with sudo ss -tlnp | grep ':80 ', stop and disable that service, then docker compose up -d again.

Browser shows "Your n8n server is configured to use a secure cookie, however you are either visiting this via an insecure URL, or using Safari". You opened n8n over plain HTTP, typically by IP. Use the HTTPS domain. Setting N8N_SECURE_COOKIE=false makes the message go away but sends your session cookie in the clear; with Caddy in front there's no reason to do it.

n8n logs a deprecation warning about WEBHOOK_URL. You copied an older config. Since n8n 2.35.0 the variable is N8N_WEBHOOK_URL; the old name still works but will be removed. Rename it in compose.yaml and docker compose up -d.

12. Next steps

Upgrade by bumping N8N_VERSION in .env, then docker compose pull && docker compose up -d. Both images share the version tag, so runner and main stay in sync. Skip the latest tag in production; n8n ships several releases a week.

Back up the n8n_data volume (docker run --rm -v n8n_n8n_data:/data -v "$PWD":/backup alpine tar czf /backup/n8n-data.tgz -C /data .) and your .env together. One without the other is useless because of the encryption key.

The default SQLite database is fine for a single user. For anything busier, move to Postgres with DB_TYPE=postgresdb; the database environment variables page lists every knob. To invite teammates, configure N8N_SMTP_* so n8n can send invite and reset emails.

n8n 3.0 is scheduled for October 2026 and drops npm installs entirely, so a Compose setup like this one is the path forward. Read the v3.0 breaking changes before you bump past 2.x.

Sources & further reading

  1. Use Docker Compose — docs.n8n.io
  2. Configure webhook URLs with reverse proxy — docs.n8n.io
  3. Set up task runners — docs.n8n.io
  4. n8n-hosting docker-caddy reference setup — github.com
  5. Caddy official Docker image — hub.docker.com
  6. Install Docker Engine on Ubuntu — docs.docker.com
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 3

Join the discussion

Sign in or create an account to comment and vote.

Priya Nair @k8s_whisperer · 21 hours ago

been wanting to spin up n8n instead of relying on cloud vendors. caddy handling ssl renewal automatically is the move here

Marco Bianchi @shipfast_marco · 19 hours ago

Yeah caddy's solid for that, but heads up—once you've got workflows running and credentials stored, the operational overhead sneaks up. You're now managing backups yourself, dealing with n8n upgrades that might break workflows, monitoring uptime on a single box with no redundancy. Cloud vendors handle all that quietly. Still worth it if you're comfortable being on-call for your automation layer.

Noor Haddad @indiehacker_noor · 5 hours ago

yeah caddy's automation is solid. curious what your workflow volumes look like once it's running—wondering if there's a quick backup strategy that doesn't require manual snapshots

Related Reading