Skip to content

Tutorial

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.

On this page
  1. What you need
  2. Prepare the server
  3. Install Docker
  4. Configure n8n
  5. Point DNS and start
  6. Update and back up
  7. Next steps

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:

bash
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/

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:

bash
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enable
sudo ufw status

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.

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):

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

Add Docker's GPG key and repository:

bash
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

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

bash
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo docker run hello-world
docker compose version

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:

bash
mkdir -p ~/n8n && cd ~/n8n

Secrets in .env

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

bash
openssl rand -hex 32

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

.env
N8N_VERSION=2.47.4
N8N_DOMAIN=n8n.example.com
[email protected]
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
bash
chmod 600 .env

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.

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:

Caddyfile
{
	email {$ACME_EMAIL}
}

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

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

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:

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:

bash
dig +short n8n.example.com A

Once DNS resolves, bring the stack up:

bash
cd ~/n8n
sudo docker compose up -d
sudo docker compose ps

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:

bash
sudo docker compose logs -f n8n
sudo docker compose logs caddy

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:

bash
curl -fsS https://n8n.example.com/healthz

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:

bash
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

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:

bash
mkdir -p backups-n8n
scp 'deploy@VPS_IP:n8n/n8n-*' 'deploy@VPS_IP:n8n/.env' ./backups-n8n/

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.

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:
bash
cd ~/n8n
sudo docker compose pull
sudo docker compose down
sudo docker compose up -d
sudo docker compose logs -f n8n

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.
Fernando Chagas

Building Nuvits: VPS paid in Brazilian reais, with MCP for AI agents.

Ler em português