Skip to content
Cloud & Infra Intermediate Tutorial

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

Run your own n8n behind automatic HTTPS in six steps, with working webhooks and no Zapier bill.

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

What you'll build

A production-style n8n instance on a small Ubuntu VPS, run with Docker Compose and fronted by Caddy, which fetches and renews a Let's Encrypt certificate for you. When you're done, https://n8n.yourdomain.com serves the editor, and webhook and schedule triggers fire against a URL that external services can actually reach.

Prerequisites

  • A VPS running Ubuntu 24.04 LTS (22.04 also works) with a public IPv4 address, 1 vCPU and 2 GB RAM minimum, and sudo access. Commands below assume you're SSH'd in as a non-root sudo user.
  • A domain with a DNS A record (e.g. n8n.example.com) already pointing at the VPS IP. Caddy needs this resolving before it starts, or certificate issuance fails.
  • Ports 80 and 443 reachable from the internet. Check your provider's cloud firewall as well as the host firewall.
  • Versions verified for this tutorial: n8n 2.36.7 (the current latest image tag), Caddy 2.11.4, and Docker Engine with the Compose v2 plugin installed from Docker's own apt repo.

1. Install Docker Engine and Compose

Ubuntu's docker.io package lags; use Docker's repository so you get the Compose v2 plugin (docker compose, not the old docker-compose).

sudo apt update
sudo apt install -y ca-certificates curl
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

sudo tee /etc/apt/sources.list.d/docker.sources <<EOT
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOT

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Let your user run Docker without sudo, then pick up the new group without logging out:

sudo usermod -aG docker $USER
newgrp docker
docker compose version

2. Open the firewall

If you use ufw, allow SSH first so you don't lock yourself out, then HTTP/HTTPS (443/udp is for HTTP/3, which Caddy enables by default):

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

3. Create the project and .env

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

Replace the hostname and timezone (use an IANA name; it drives Schedule Trigger nodes). n8n would generate an encryption key on first boot and stash it in the data volume, but pinning it in .env means a lost volume doesn't also mean unreadable credential backups. Back this file up — without the key, stored credentials are gone.

4. Write compose.yaml

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

  n8n:
    image: n8nio/n8n:2.36.7
    restart: unless-stopped
    environment:
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_EDITOR_BASE_URL=https://${N8N_HOST}/
      - N8N_WEBHOOK_URL=https://${N8N_HOST}/
      - N8N_PROXY_HOPS=1
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
      - NODE_ENV=production
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  caddy_data:
  caddy_config:
  n8n_data:

Three things worth noting. n8n has no ports: entry on purpose — it's only reachable through Caddy on the Compose network, so nobody can hit the unencrypted :5678 directly. N8N_WEBHOOK_URL is what makes the editor display (and register with third parties) https://n8n.example.com/webhook/... instead of http://localhost:5678/...; the old WEBHOOK_URL name still works but is deprecated since n8n 2.35.0. And N8N_PROXY_HOPS=1 tells n8n to trust one proxy's X-Forwarded-For, so rate limiting and IP allowlists see real client IPs. Task runners are on by default in n8n 2.x, so the N8N_RUNNERS_ENABLED flag from older guides is no longer needed.

caddy_data persists certificates and account keys. Delete it casually and you'll burn through Let's Encrypt's rate limits re-issuing.

5. Write the Caddyfile

{$N8N_HOST} {
	reverse_proxy n8n:5678
}

That's the whole thing. A bare hostname as the site address triggers Caddy's automatic HTTPS: it obtains a certificate, redirects HTTP to HTTPS, and renews on its own. reverse_proxy handles the WebSocket upgrade n8n's editor uses without extra directives. {$N8N_HOST} reads the environment variable we passed in from .env.

6. Start the stack

docker compose up -d
docker compose logs -f caddy

Within a few seconds you should see a certificate obtained successfully line from the tls.obtain logger. Hit Ctrl+C to stop following the logs; the containers keep running.

Verify it works

Check both containers are up:

docker compose ps
NAME          IMAGE             COMMAND                  SERVICE   CREATED          STATUS          PORTS
n8n-caddy-1   caddy:2.11.4      "caddy run --config …"   caddy     40 seconds ago   Up 39 seconds   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:443->443/udp, 2019/tcp
n8n-n8n-1     n8nio/n8n:2.36.7  "tini -- /docker-ent…"   n8n       40 seconds ago   Up 39 seconds   5678/tcp

Confirm TLS and the health endpoint from outside:

curl -sI https://n8n.example.com/healthz | head -1
curl -s https://n8n.example.com/healthz
HTTP/2 200
{"status":"ok"}

Open https://n8n.example.com in a browser. You'll get the owner-account setup form; fill it in — the first account created is the instance owner. Then prove webhooks work end to end: create a workflow, add a Webhook trigger node, set its path to hello, and click Publish. The node's Production URL should read https://n8n.example.com/webhook/hello. Call it:

curl -s https://n8n.example.com/webhook/hello
{"message":"Workflow was started"}

That's the Webhook node's default immediate response, and the execution will appear under the workflow's Executions tab. If the URL had shown localhost, N8N_WEBHOOK_URL isn't reaching the container.

Troubleshooting

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — your shell doesn't have the docker group yet. Run newgrp docker or log out and back in; groups should list docker.

Caddy logs "logger":"tls.obtain","msg":"could not get certificate from issuer" with an acme error such as urn:ietf:params:acme:error:unauthorized or dns — the CA couldn't reach your host on port 80/443 or the A record doesn't resolve to it. Check dig +short n8n.example.com matches the VPS IP and that the provider firewall allows 80 and 443. Caddy retries with backoff on its own once you fix it; no restart needed.

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 http://<ip>:5678 after adding a ports: mapping). Use the HTTPS hostname. Don't paste in the N8N_SECURE_COOKIE=false workaround from forums; it sends your session cookie in the clear.

Webhook node shows http://localhost:5678/webhook/...N8N_WEBHOOK_URL is missing or .env isn't being read. Run docker compose config | grep WEBHOOK_URL to see what Compose actually resolved, fix, then docker compose up -d to recreate the container.

Next steps

  • Upgrade deliberately. Bump the n8nio/n8n tag in compose.yaml, then docker compose pull && docker compose down && docker compose up -d. Pinned tags beat latest for an app that owns your credentials.
  • Back up two things: the n8n_data volume (SQLite DB, settings) and .env (encryption key). One without the other is useless.
  • Move to PostgreSQL (DB_TYPE=postgresdb and the DB_POSTGRESDB_* variables) once you have more than hobby load; SQLite is fine to start but is single-writer.
  • Harden Code nodes by running task runners in N8N_RUNNERS_MODE=external — the docs call internal mode unsuitable for production because escaped code sees n8n's secrets.
  • Enforce MFA for all users with N8N_MFA_ENFORCED_ENABLED=true before you invite teammates.

Sources & further reading

  1. Install with Docker - n8n Docs — docs.n8n.io
  2. Configure webhook URLs with reverse proxy - n8n Docs — docs.n8n.io
  3. Deployment environment variables - n8n Docs — docs.n8n.io
  4. Set up task runners - n8n Docs — docs.n8n.io
  5. Automatic HTTPS - Caddy Documentation — caddyserver.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 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading