Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

How to Self-Host n8n with Docker on Ubuntu 24.04

Published

Self Hosted

n8n can run in a single Docker container, but a public port is not a finished deployment. The editor needs HTTPS, webhooks need the correct public URL, and application data needs persistent storage.

This guide runs n8n behind Caddy on a fresh Ubuntu 24.04 VPS. It uses SQLite for a small, single-instance installation and keeps the complete n8n data directory on disk.

Prepare the VPS and domain

Use a fresh server with sudo access, at least 2 GB RAM, and Docker Engine 28 or later with Compose v2. This is a starting configuration for modest workflows, not a capacity guarantee. Do not apply the Caddy setup unchanged to a server already running websites or a hosting panel.

Create an A record for n8n.example.com pointing to the VPS public IPv4 address. Replace that domain everywhere below. Leave DNS proxying disabled for this guide; Caddy is the only proxy. Add an AAAA record only if IPv6 actually reaches this server.

Inspect the server before installing anything:

$ sudo ss -lntp
$ sudo ufw status verbose
$ sudo apt-get update
$ sudo apt-get install -y docker.io docker-compose-v2 caddy curl ufw
$ sudo docker version --format '{{.Server.Version}}'
$ sudo docker compose version

These package names are for a fresh Ubuntu installation. If Docker is already installed from Docker’s repository, keep that installation instead of mixing packages. Docker releases before 28 have a localhost-publishing caveat affecting peers on the same network.

Allow SSH and Caddy, preserving any existing firewall rules. Substitute your actual SSH port if it is not 22, and keep a second SSH session open while changing UFW.

$ sudo ufw allow 22/tcp
$ sudo ufw allow 80/tcp
$ sudo ufw allow 443/tcp
$ sudo ufw default deny incoming
$ sudo ufw default allow outgoing
$ sudo ufw --force enable
$ sudo systemctl enable --now docker

There is no public allow rule for port 5678. Caddy will run on the host, with n8n bound only to localhost.

Create the n8n project

Run these commands as your normal sudo-capable user:

$ mkdir -p ~/n8n
$ chmod 700 ~/n8n
$ cd ~/n8n
$ sudo install -d -o 1000 -g 1000 -m 700 data

The official n8n image runs as UID 1000. Setting the data directory’s owner now avoids a permission error when n8n creates its database.

Create ~/n8n/.env with these contents:

N8N_VERSION=2.38.5
N8N_DOMAIN=n8n.example.com
GENERIC_TIMEZONE=Etc/UTC

Use your domain and an IANA timezone such as Europe/London. The pinned release makes this installation reproducible; review n8n’s release notes before choosing a newer stable version.

Create ~/n8n/compose.yaml:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    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_SECURE_COOKIE: "true"
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${GENERIC_TIMEZONE}
      N8N_DIAGNOSTICS_ENABLED: "false"
    volumes:
      - ./data:/home/node/.n8n
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

N8N_WEBHOOK_URL sets the public webhook address; without it, generated URLs can include the internal port. N8N_PROXY_HOPS=1 matches the single Caddy proxy. The old WEBHOOK_URL spelling is deprecated in n8n 2.35 and later.

The data directory stores the database and n8n configuration outside the container. Keep this volume mapping so your account, workflows, and credentials persist when the container is replaced.

Start n8n and check the listener:

$ sudo docker compose config --quiet
$ sudo docker compose up -d
$ sudo docker compose port n8n 5678
127.0.0.1:5678
$ curl -fsS http://127.0.0.1:5678/healthz
{"status":"ok"}

The localhost binding keeps the backend private. Startup includes database migrations; if the health request fails immediately, check the logs and retry when n8n is ready:

$ sudo docker compose logs --tail=60 n8n

Create the owner before opening the editor

Do not leave an unclaimed setup page on the public internet. On your own computer, open an SSH tunnel, substituting your SSH user and server address:

$ ssh -N -L 5678:127.0.0.1:5678 ubuntu@YOUR_VPS_IP

Keep that terminal open. Visit http://localhost:5678 in your browser and complete the owner-account form with your email and a unique password. Use the literal hostname localhost, not the VPS IP. Skip optional AI Assistant onboarding if it appears; no AI-provider account is needed here. Once the editor opens, close the tunnel with Ctrl+C. The account is now stored in the persistent database.

Enable HTTPS with Caddy

On this fresh server, inspect and preserve the package’s existing Caddyfile before replacing its welcome site:

$ sudo cat /etc/caddy/Caddyfile
$ sudo cp -a /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-n8n

Replace /etc/caddy/Caddyfile with:

n8n.example.com {
    reverse_proxy 127.0.0.1:5678
}

Caddy handles HTTP-to-HTTPS redirects and certificate renewal. Its reverse proxy supports WebSockets and supplies the forwarded headers n8n needs. Keep ports 80 and 443 reachable from the internet for certificate validation and HTTPS.

$ sudo caddy validate --config /etc/caddy/Caddyfile
$ sudo systemctl enable --now caddy
$ sudo systemctl reload caddy
$ curl -I http://n8n.example.com
$ curl -fsS https://n8n.example.com/healthz

The HTTP request should redirect to HTTPS; the HTTPS request must succeed without -k. Open https://n8n.example.com and sign in. Check that the browser reports a valid certificate and the editor stays connected.

From a different computer, test the raw application port:

$ curl --connect-timeout 5 http://YOUR_VPS_IP:5678/healthz

This request should fail to connect. If it succeeds, check docker compose port n8n 5678: a mapping such as 0.0.0.0:5678 exposes the backend. UFW alone does not reliably block a broadly published Docker port.

Prove that a production webhook works

Create a workflow in the editor and add a Webhook node. Select GET, set the path to kb-health, leave authentication as None for this harmless test, and set Respond to Immediately. Save and publish the workflow. Publishing is what registers the production URL; listening for a test event is not the same thing.

Copy the node’s Production URL. It should start with https://n8n.example.com/webhook/, without :5678 or webhook-test. From your own computer, call it:

$ curl -fsS https://n8n.example.com/webhook/kb-health
{"message":"Workflow was started"}

Check the workflow’s executions list for a successful run. Unpublish the test when finished. Before using a webhook for real data or actions, configure authentication in the Webhook node; signing in to the editor does not protect webhook URLs.

Troubleshoot the installation

A failed image pull can be a registry or host-DNS problem. Resolve the host connectivity error and retry; it is not a reason to remove the data directory.

  • Certificate errors: check A and AAAA records, ports 80/443, and sudo journalctl -u caddy -n 60 --no-pager. Do not disable secure cookies to hide a TLS problem.
  • Webhook returns 404: check that the workflow is published and that you copied its production URL. Test URLs are temporary.
  • Permission denied under /home/node/.n8n: check that the host data directory belongs to UID 1000 and was not created by the editor as a root-only directory.
  • External API requests time out: if the host can reach the internet but n8n cannot, check Docker networking and the provider’s routing requirements. Our dual-address lab needed a provider-specific outbound source setting; it is not a standard installation step.

Related guides

Account services are unavailable until the production backend is configured.