DEV Community

Fernando Chagas
Fernando Chagas

Posted on Originally published at nuvits.net

How to install n8n with Docker on a VPS

This guide sets up self-hosted n8n for light production use on an Ubuntu 24.04 VPS: n8n backed by PostgreSQL, the task runner in its own container (which is what the n8n docs recommend for production), and Caddy in front to issue and renew the HTTPS certificate. The whole stack runs on Docker Compose from three files, and the guide also covers updates and backups.

What you need

  • A VPS running 64-bit Ubuntu 24.04, with SSH access as a user that has sudo.
  • A domain where you can create DNS records. The examples use n8n.example.com.
  • An email address for the certificate's ACME account.

The n8n docs don't set a hardware minimum for this exact setup. The official Docker Compose guide asks for 2 vCPUs and 4 GB of RAM once you add the n8n Assistant sandbox. This stack runs four containers (Caddy, n8n, the runner and PostgreSQL), so treat 2 vCPUs and 4 GB as the floor. With 4 vCPUs and 8 GB you have room for executions that move more data and for the database to grow.

If you just want to try n8n, Nuvits can also deliver the VPS with n8n preinstalled when you order it. That option runs n8n in a single container, without PostgreSQL or an external runner, and it only answers inside the VPS: you open the editor through an SSH tunnel. To receive webhooks from the internet with a domain and HTTPS, follow this guide.

Prepare the server

If your provider hands you the VPS with root access, create a user with sudo and copy your SSH key over:

adduser deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy ~/.ssh/authorized_keys /home/deploy/.ssh/
Enter fullscreen mode Exit fullscreen mode

Open a second session as deploy and make sure you can log in before closing the root one. Then allow SSH, HTTP and HTTPS in ufw and turn the firewall on:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enable
sudo ufw status
Enter fullscreen mode Exit fullscreen mode

ufw enable asks for confirmation because it may drop the SSH connection; with the port 22 rule already in place, answer y. The 443 rule has no protocol, so it covers both TCP and UDP. UDP is for HTTP/3, which Caddy also serves.

Docker bypasses ufw. A port published with ports: in Compose is reachable from outside even without a ufw rule, because Docker routes that traffic before it reaches the firewall's rules. That's why only Caddy publishes ports (80 and 443) in this guide. n8n and PostgreSQL stay on the internal Compose network.

Install Docker

At Nuvits you can have Docker preinstalled when you order the VPS. If that's your case, skip ahead to docker compose version at the end of this section.

For servers, the Docker docs point to the official apt repository. First remove any unofficial packages that could conflict (on a fresh install, apt will usually report there's nothing to remove):

sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
Enter fullscreen mode Exit fullscreen mode

Add Docker's GPG key and repository:

sudo apt update
sudo apt install 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 <<EOF
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
EOF

sudo apt update
Enter fullscreen mode Exit fullscreen mode

Install Docker Engine and the Compose plugin, then check that everything works:

sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo docker run hello-world
docker compose version
Enter fullscreen mode Exit fullscreen mode

There's also the get.docker.com convenience script, but Docker's own docs recommend it only for testing and development, not production.

The commands in this guide use sudo docker. You can add your user to the docker group to drop the sudo, but the Docker docs warn that the group grants root-level privileges.

Configure n8n

Create a project directory. Compose uses its name as the prefix for volumes and containers:

mkdir -p ~/n8n && cd ~/n8n
Enter fullscreen mode Exit fullscreen mode

Secrets in .env

Generate three random values: one for the encryption key, one for the runner token and one for the database password.

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

Run it three times and paste the results into .env:

N8N_VERSION=2.47.4
N8N_DOMAIN=n8n.example.com
ACME_EMAIL=you@example.com
GENERIC_TIMEZONE=America/New_York

N8N_ENCRYPTION_KEY=paste-the-first-value-here
N8N_RUNNERS_AUTH_TOKEN=paste-the-second-value-here

POSTGRES_USER=n8n
POSTGRES_PASSWORD=paste-the-third-value-here
POSTGRES_DB=n8n
Enter fullscreen mode Exit fullscreen mode
chmod 600 .env
Enter fullscreen mode Exit fullscreen mode

N8N_VERSION pins both the n8n and the runner images, which must run the same version. Use the latest stable release listed on n8n's releases page; 2.47.4 was the stable version when this was written. Set GENERIC_TIMEZONE to your own IANA time zone name, such as Europe/Berlin or America/Sao_Paulo.

Keep the key safe. n8n uses N8N_ENCRYPTION_KEY to encrypt the credentials it stores in the database. Without it, a restored backup will load, but none of its credentials can be decrypted. Keep a copy of .env somewhere off the VPS, such as a password manager.

Caddyfile

Caddy gets the certificate on its own as soon as it sees a domain name in the site address. flush_interval -1 turns off response buffering, as in n8n's official Caddy example:

{
    email {$ACME_EMAIL}
}

{$N8N_DOMAIN} {
    reverse_proxy n8n:5678 {
        flush_interval -1
    }
}
Enter fullscreen mode Exit fullscreen mode

Caddy fills in {$N8N_DOMAIN} and {$ACME_EMAIL} from the container's environment, which Compose sets from .env. reverse_proxy already sends the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers that n8n needs behind a proxy.

docker-compose.yml

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

  postgres:
    image: postgres:18
    restart: always
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
      - PGDATA=/var/lib/postgresql/data
    volumes:
      - db_storage:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: always
    environment:
      - N8N_HOST=${N8N_DOMAIN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_WEBHOOK_URL=https://${N8N_DOMAIN}/
      - N8N_PROXY_HOPS=1
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
      - 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_RUNNERS_MODE=external
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

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

volumes:
  caddy_data:
  caddy_config:
  db_storage:
  n8n_data:
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing about this file:

  • N8N_WEBHOOK_URL and N8N_PROXY_HOPS=1 make n8n build webhook URLs with the public domain and trust Caddy's forwarded headers. Without them, the editor shows URLs with the internal port 5678. Before n8n 2.30.0 the variable was called WEBHOOK_URL, which is now deprecated.
  • The external runner executes Code node scripts outside the n8n process. Internal mode, the default, is deprecated, and the docs advise against it in production: code that escapes the sandbox gets the same access as n8n, credentials included.
  • Pinning PGDATA keeps Postgres 18's data inside the volume. Don't remove that line. Without it, Postgres 18 writes outside the volume and the database starts empty.
  • n8n keeps its volume at /home/node/.n8n even with PostgreSQL. The docs recommend keeping that folder persistent.
  • Neither n8n nor Postgres publishes a port. Caddy reaches n8n by its service name on the Compose network.

Point DNS and start

In your domain's DNS settings, create an A record named n8n (or whatever subdomain you picked) pointing to the VPS's IPv4 address. Before starting the stack, check that the name already resolves to the right IP:

dig +short n8n.example.com A
Enter fullscreen mode Exit fullscreen mode

Once DNS resolves, bring the stack up:

cd ~/n8n
sudo docker compose up -d
sudo docker compose ps
Enter fullscreen mode Exit fullscreen mode

n8n only starts after Postgres passes its healthcheck. Follow the n8n and Caddy logs until the editor is up and the certificate has been issued:

sudo docker compose logs -f n8n
sudo docker compose logs caddy
Enter fullscreen mode Exit fullscreen mode

If the certificate doesn't come through, check that DNS has propagated and that ports 80 and 443 are also open in your provider's firewall, if it has one. Once that's fixed, run sudo docker compose restart caddy.

Then test from outside the VPS:

curl -fsS https://n8n.example.com/healthz
Enter fullscreen mode Exit fullscreen mode

Open https://n8n.example.com in your browser and create the owner account. To check webhooks, add a Webhook node to a workflow: its production URL should start with https://n8n.example.com/webhook/.

Update and back up

Backups

A full backup of n8n on PostgreSQL has two parts: the database, and the .n8n folder that holds the configuration and the encryption key. With the stack running:

cd ~/n8n
set -a && . ./.env && set +a

sudo docker compose exec -T postgres pg_dumpall -U "$POSTGRES_USER" > n8n-db-$(date +%F).sql
grep -c "PostgreSQL database cluster dump complete" n8n-db-$(date +%F).sql

sudo docker run --rm --volumes-from "$(sudo docker compose ps -q n8n)" -v "$PWD":/backup ubuntu:24.04 tar czf /backup/n8n-data-$(date +%F).tar.gz /home/node/.n8n
Enter fullscreen mode Exit fullscreen mode

The grep must print 1. If it prints 0, the dump is incomplete. Then, from your own machine, copy both files and .env off the VPS:

mkdir -p backups-n8n
scp 'deploy@VPS_IP:n8n/n8n-*' 'deploy@VPS_IP:n8n/.env' ./backups-n8n/
Enter fullscreen mode Exit fullscreen mode

A backup that only lives on the VPS's disk is gone if you lose the VPS. To restore, stop n8n, put back the .n8n folder and the database, and start again with the same .env. The Back up and restore page walks through each step.

Never run docker compose down -v in this directory. The -v also deletes the volumes, including n8n_data with the encryption key and db_storage with the database.

Updates

n8n ships a new minor version most weeks. The docs suggest updating at least once a month, so you don't have to jump several versions at once, and reading the release notes first. The routine:

  1. Take the backup above.
  2. Change N8N_VERSION in .env to the new stable version. The runner follows along, since it uses the same variable.
  3. Pull the images and recreate the containers:
cd ~/n8n
sudo docker compose pull
sudo docker compose down
sudo docker compose up -d
sudo docker compose logs -f n8n
Enter fullscreen mode Exit fullscreen mode

n8n runs its database migrations on startup. Switching the tag back doesn't undo them, which is why the backup comes first. docker compose pull also fetches the latest Caddy 2.11 and PostgreSQL 18 images. Moving Postgres to a new major version (18 to 19, for example) takes a dump and restore, not just a tag change.

Next steps

  • Turn on two-factor authentication for the owner account and set up SMTP for invites and password resets, using the user management variables.
  • Schedule the backup with cron and ship the copies somewhere else automatically.
  • Give n8n its own Postgres user without superuser rights, as in the official withPostgres example.
  • Read the task runner hardening guide if more people will be editing workflows on the instance.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

The note that Docker-published ports bypass UFW is the production detail many quick-start guides omit. I would add a restore drill to the backup section: restore PostgreSQL plus the n8n encryption key into an isolated environment and execute a harmless credentialed workflow. A backup that has not been tested with its encryption material is only an assumption.