n8n Automation Tutorial Intermediate

How to Deploy n8n on an Ubuntu VPS with Docker

A production n8n install on Ubuntu 24.04 using Docker Compose, PostgreSQL and Caddy for automatic TLS — with the encryption key, webhook URL and backup steps most quickstarts leave out.

An n8n workflow canvas running behind a Docker Compose stack on a VPS

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Docker
27.x
DockerCompose
v2.29
N8n
1.60
Postgresql
17
Caddy
2.8

Before you start

  • An Ubuntu 24.04 VPS with at least 2 GB RAM and a public IPv4 address
  • A domain name whose DNS you control
  • SSH access as a non-root user with sudo
On this page

A self-hosted n8n that survives contact with production needs three containers, not one: n8n, PostgreSQL, and a reverse proxy that terminates TLS.

Everything below was run on a fresh Ubuntu 24.04 LTS VPS with 2 vCPU and 4 GB RAM.

What you will end up with

n8n on https://n8n.example.com, with a certificate issued and renewed automatically, workflow data in PostgreSQL, and port 5678 reachable only from inside the Compose network.

This guide does not cover queue mode with Redis workers, which you need once a single n8n process can no longer keep up with concurrent executions. It also does not cover n8n’s enterprise features.

Requirements

  • Ubuntu 24.04 LTS, 2 GB RAM minimum. n8n plus Postgres idles around 700 MB.
  • A domain with an A record pointing at the server’s public IPv4 address. Create it now — Caddy needs it resolving before it can get a certificate.
  • Ports 80 and 443 open. Port 80 is required for the ACME HTTP challenge even though all traffic ends up on 443.

Verify DNS has propagated before you start:

bash
dig +short n8n.example.com
Expected output
bash
203.0.113.42

If that returns nothing, stop and fix DNS. Caddy will otherwise fail its certificate request and retry with a backoff that gets slow quickly.

Install Docker and prepare the directory

Use Docker’s own apt repository. The docker.io package in Ubuntu’s archive lags and does not include the Compose v2 plugin. Docker’s convenience script configures that repository for you — download it and read it before running it as root.

bash
curl -fsSL https://get.docker.com -o get-docker.sh
less get-docker.sh          # confirm what it will do before granting root
sudo sh get-docker.sh
sudo usermod -aG docker "$USER"
newgrp docker
docker compose version
Expected output
bash
Docker Compose version v2.29.7

Create the stack directory:

  • /opt/n8n/
    • compose.yaml
    • Caddyfile
    • .env
bash
sudo mkdir -p /opt/n8n
sudo chown "$USER":"$USER" /opt/n8n
cd /opt/n8n

Write the Compose file

/opt/n8n/compose.yaml
services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:1.60
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: "5432"
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_HOST: ${N8N_HOST}
      N8N_PORT: "5678"
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://${N8N_HOST}/
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
      TZ: ${GENERIC_TIMEZONE}
      N8N_RUNNERS_ENABLED: "true"
    volumes:
      - n8n_data:/home/node/.n8n

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    depends_on:
      - n8n
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  postgres_data:
  n8n_data:
  caddy_data:
  caddy_config:

Three details in there are load-bearing.

The n8n service publishes no ports. Caddy reaches it as n8n:5678 over the Compose network. If you publish 5678 you have also published an unauthenticated setup page to the internet for however long it takes you to create the owner account.

$${POSTGRES_USER} in the healthcheck is escaped so Compose passes a literal ${POSTGRES_USER} into the container’s shell rather than substituting it at parse time.

caddy_data must be a named volume. It holds the issued certificates and the ACME account key; losing it means re-issuing from scratch and burning Let’s Encrypt rate limit.

The request path is worth holding in your head, because every failure below maps onto one hop of it. A browser resolves the hostname to the server, Caddy accepts the TLS connection on 443, proxies plain HTTP to n8n:5678 over the Compose network, and n8n reads and writes workflow state in Postgres over the same network. Only Caddy publishes ports; the other two services exist solely on that internal network.

Compose creates a default network named after the project directory and attaches every service to it, which is why postgres and n8n resolve as hostnames without any explicit network configuration.

Caddyfile and environment

The Caddyfile is two lines. Caddy requests and renews the certificate on its own.

/opt/n8n/Caddyfile
n8n.example.com {
	reverse_proxy n8n:5678
}

Generate a real encryption key rather than typing one:

bash
openssl rand -base64 32
/opt/n8n/.env
N8N_HOST=n8n.example.com
GENERIC_TIMEZONE=Europe/Berlin

POSTGRES_USER=n8n
POSTGRES_PASSWORD=change-me-to-a-long-random-string
POSTGRES_DB=n8n

N8N_ENCRYPTION_KEY=paste-the-openssl-output-here
bash
chmod 600 /opt/n8n/.env

Start the stack and create the owner account

bash
cd /opt/n8n
docker compose up -d
docker compose ps
Expected output
bash
NAME            IMAGE                            STATUS
n8n-caddy-1     caddy:2-alpine                   Up 24 seconds
n8n-n8n-1       docker.n8n.io/n8nio/n8n:1.60     Up 24 seconds
n8n-postgres-1  postgres:17-alpine               Up 34 seconds (healthy)

Watch Caddy get its certificate:

bash
docker compose logs -f caddy

Look for certificate obtained successfully. Then open https://n8n.example.com and create the owner account immediately. Until you do, anyone who reaches that URL can claim it.

n8n replaced the old N8N_BASIC_AUTH_* variables with built-in user management. If a tutorial tells you to set them, it predates that change.

Verify the deployment

n8n exposes a health endpoint that does not require authentication:

bash
curl -fsS https://n8n.example.com/healthz
Expected output
bash
{"status":"ok"}

Confirm the database is actually being used — an empty output here means n8n silently fell back to SQLite:

bash
docker compose exec postgres psql -U n8n -d n8n -c '\dt' | head

You should see tables including workflow_entity, credentials_entity and execution_entity.

Finally, confirm 5678 is not exposed. From your laptop:

bash
curl --max-time 5 http://203.0.113.42:5678/

That must time out or be refused.

Common errors

getaddrinfo ENOTFOUND postgres — n8n started before Postgres, or the service name does not match DB_POSTGRESDB_HOST. The condition: service_healthy dependency above handles the first case; check spelling for the second.

password authentication failed for user "n8n" — you changed POSTGRES_PASSWORD after the volume was created. Postgres only reads POSTGRES_PASSWORD when it initialises an empty data directory. Either set the password inside the database with ALTER ROLE, or destroy the volume and start over.

Mismatching encryption keys in the n8n logs, or “Credentials could not be decrypted” in the UI — N8N_ENCRYPTION_KEY differs from the value used when the credentials were saved. n8n also persists a copy at /home/node/.n8n/config; if the environment variable and that file disagree, n8n refuses to start.

Caddy logs obtaining certificate ... connection refused — port 80 is blocked. Check sudo ufw status, and check whether your provider has a separate cloud firewall in front of the instance.

Webhook URLs contain localhost:5678WEBHOOK_URL is missing. Set it to the full public URL with a trailing slash and restart n8n. Existing workflows pick up the new URL, but any webhook you already registered with a third party has to be updated there too.

EACCES: permission denied, open '/home/node/.n8n/config' — you replaced the named volume with a host bind mount. The n8n image runs as UID 1000; run sudo chown -R 1000:1000 /your/host/path.

Backups, upgrades and security notes

A database dump plus the encryption key is a complete backup. Run it nightly:

/opt/n8n/backup.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n
stamp=$(date +%F)
docker compose exec -T postgres pg_dump -U n8n -Fc n8n > "/var/backups/n8n-${stamp}.dump"
find /var/backups -name 'n8n-*.dump' -mtime +14 -delete

-Fc produces the custom format, which pg_restore can read selectively. Test a restore into a scratch database at least once — an untested backup is a hypothesis.

Firewall rules: allow 22, 80 and 443 and nothing else.

bash
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Pin the n8n image to a minor tag rather than latest. n8n runs schema migrations on start, and an unattended latest pull during a docker compose up -d can migrate the database out from under a version you have not tested.

If your workflows execute arbitrary JavaScript through the Code node, treat the n8n container as a code-execution surface. Keep it off any network segment that can reach your internal systems unless a workflow genuinely needs to.

Frequently asked questions

Do I need PostgreSQL, or is the default SQLite enough?

SQLite is fine for a personal instance with a handful of workflows. Move to PostgreSQL before you have concurrent executions or more than a few thousand execution records — SQLite write contention shows up as slow or stuck executions long before the database gets large.

What happens if I lose N8N_ENCRYPTION_KEY?

Every stored credential becomes unreadable. n8n encrypts credentials with that key, and there is no recovery path — you re-enter every API key and OAuth connection by hand. Back the key up separately from the database.

Why do my webhook URLs show localhost or an IP address?

`WEBHOOK_URL` is unset or wrong. n8n builds production webhook URLs from that variable, not from the request host, so it must be the full public URL including the scheme and a trailing slash.

Can I run this without a domain name?

You can reach the UI over plain HTTP on the server IP, but do not. n8n stores API keys and OAuth tokens; without TLS they cross the network in cleartext, and OAuth callbacks from most providers require HTTPS anyway.

How do I upgrade n8n safely?

Take a database dump, change the image tag in `compose.yaml`, then run `docker compose pull && docker compose up -d`. Read the release notes for breaking changes first — n8n occasionally migrates the database schema on start, and that migration is not reversible.

Sources

  1. n8n Docker installation n8n Primary source Accessed 20 Jun 2026
  2. n8n environment variables reference n8n Primary source Accessed 20 Jun 2026
  3. Install Docker Engine on Ubuntu Docker Primary source Accessed 20 Jun 2026
  4. Caddyfile concepts Caddy Primary source Accessed 20 Jun 2026
  5. pg_dump PostgreSQL Global Development Group Primary source Accessed 20 Jun 2026
5 min read

Part of a learning path

  • Security intermediate

    How to Secure a VPS That Runs Docker Containers

    SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

    4 min
  • Docker intermediate

    How to Back Up Docker Volumes Automatically

    A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.

    4 min AI-assisted
  • Cloudflare beginner

    How to Point a Cloudflare Domain at Your VPS

    Create the A record, pick the right proxy status, set the SSL mode to Full (strict) and lock the origin so only Cloudflare can reach it — plus what errors 521, 522, 525 and 526 actually mean.

    4 min