TKVibes Blog All topics RSS
Browse topics

How to Install n8n on a VPS with Docker in 2026

Updated 2026-10-3112 min read

Install n8n on a VPS with Docker Compose, PostgreSQL, an external task-runner sidecar, a localhost-only port 5678, and Caddy HTTPS. A 4 GB VPS is a sensible starting point for light production use; pin a stable n8n version, preserve the encryption key, and back up PostgreSQL before every update.

Installing n8n is easy. Keeping it private, recoverable, and updateable is the real job. A bare docker run command can open the editor, but it does not give you a production database, HTTPS, an off-server backup, or a safe update path. This guide builds those pieces into one small VPS setup.

I checked the commands and release state against n8n's official Docker Compose guidance, task-runner documentation, update documentation, official hosting repository, and GitHub API on 31 October 2026. I passed the Compose file below through docker compose config with Docker Engine 29.8.0 and Docker Compose v5.5.1. It returned exit code 0, resolved postgres:18, docker.n8n.io/n8nio/n8n:2.45.7, and n8nio/runners:2.45.7, and kept port 5678 on 127.0.0.1. This catches interpolation, image-tag, YAML, and public-binding errors. It cannot prove that DNS, the firewall, or the provider network is correct, so the external checks later still matter.

The short answer and deployment choices

ChoiceGood forMain drawbackMy verdict
n8n + SQLite in one containerLocal trialWeak fit for concurrent production work and harder database operationsUse only to learn
n8n + PostgreSQL + runner + CaddySmall production VPSYou own updates and backupsBest default
n8n CloudNo server maintenanceRecurring managed-service cost and plan limitsBuy when your time matters more
Queue mode with workersHigh execution volumeRedis, workers, and more operational workAdd only after measuring

For a normal agency or small-business installation, use one n8n container, one PostgreSQL container, and Caddy on the host. Start with 4 GB RAM, then measure. The software can start with less, but the database, Docker, operating system, execution payloads, and update headroom all share the same memory.

The official n8n Docker documentation still uses a persistent /home/node/.n8n volume even with PostgreSQL. Keep it. That directory contains data beyond the SQLite file, including encryption and instance material. This setup also sets N8N_ENCRYPTION_KEY explicitly so a recreated volume does not make saved credentials unreadable.

What you need before installing n8n

You need an Ubuntu or Debian VPS, a domain or subdomain, root or sudo access, and inbound ports 80 and 443. Point an A record such as n8n.example.com to the server before asking Caddy for a certificate.

Check DNS from your own computer:

dig +short n8n.example.com

It should return the VPS public IP. If it does not, wait for DNS or fix the record first. Certificate issuance will fail when the name resolves elsewhere.

Install Docker Engine from Docker's official convenience script, then confirm the Compose plugin exists:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version

For a business server, pinning and updating Docker through its apt repository gives tighter change control. The convenience script is the shortest repeatable route for a fresh VPS; read it before execution if that tradeoff does not suit your environment.

Create the n8n Docker Compose stack

Create a dedicated directory:

sudo mkdir -p /opt/n8n
sudo chown "$USER":"$USER" /opt/n8n
cd /opt/n8n

Save this as /opt/n8n/compose.yml:

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

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: unless-stopped
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_HOST: ${DOMAIN}
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      NODE_ENV: production
      WEBHOOK_URL: https://${DOMAIN}/
      N8N_PROXY_HOPS: 1
      GENERIC_TIMEZONE: ${TZ}
      TZ: ${TZ}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_RUNNERS_MODE: external
      N8N_RUNNERS_AUTH_TOKEN: ${RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      EXECUTIONS_DATA_PRUNE: "true"
      EXECUTIONS_DATA_MAX_AGE: 168
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: unless-stopped
    environment:
      N8N_RUNNERS_AUTH_TOKEN: ${RUNNERS_AUTH_TOKEN}
      N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
    depends_on:
      - n8n

volumes:
  postgres_data:
  n8n_data:

The official n8n hosting repository used PostgreSQL 18 and pinned PGDATA=/var/lib/postgresql/data when I checked it. That explicit path matters because the PostgreSQL 18 image changed its default data-directory handling. Never change a database major version casually: a new image tag is not an in-place database upgrade procedure.

Create /opt/n8n/.env without putting secrets into shell history:

cd /opt/n8n
umask 077
POSTGRES_PASSWORD="$(openssl rand -hex 24)"
N8N_ENCRYPTION_KEY="$(openssl rand -hex 32)"
RUNNERS_AUTH_TOKEN="$(openssl rand -hex 32)"
printf '%s\n' \
  'DOMAIN=n8n.example.com' \
  'TZ=Asia/Kolkata' \
  'N8N_VERSION=2.45.7' \
  "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" \
  "N8N_ENCRYPTION_KEY=$N8N_ENCRYPTION_KEY" \
  "RUNNERS_AUTH_TOKEN=$RUNNERS_AUTH_TOKEN" > .env
chmod 600 .env

On 31 October 2026, GitHub's releases/latest endpoint returned n8n@2.45.7, published 30 October, as stable. The recent feed also contained n8n@2.46.3, explicitly marked as a prerelease, and the repository had been pushed on 31 October. I pin the stable number rather than latest so updates happen when I choose. The releases/latest endpoint skips prereleases, so check it and the recent feed before copying this later:

curl -sL https://api.github.com/repos/n8n-io/n8n/releases/latest \
  | python -c "import json,sys; d=json.load(sys.stdin); print(d['tag_name'], d['published_at'], d['prerelease'])"

curl -sL 'https://api.github.com/repos/n8n-io/n8n/releases?per_page=5' \
  | python -c "import json,sys; [(print(x['tag_name'], x['published_at'], 'prerelease='+str(x['prerelease']))) for x in json.load(sys.stdin)]"

Validate before starting the containers

Run Compose's parser first:

cd /opt/n8n
docker compose config --quiet
docker compose config --images

The first command should print nothing and return exit code 0. The second should show the n8n, task-runner, and PostgreSQL images after variable substitution. This cheap test catches malformed YAML, missing variables, and an accidental blank version before any image is pulled.

The runner image version must match n8n. n8n's current documentation says internal task-runner mode is not recommended for production and is deprecated from n8n 3.0. The separate runner isolates Code-node execution from the main n8n container; do not expose its broker port publicly.

Now start the database and n8n:

docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 n8n

Wait until the service is ready, then test only through localhost on the VPS:

curl -I http://127.0.0.1:5678/

Do not replace the port mapping with 5678:5678. Docker-published ports can bypass UFW's normal filtering path. Binding 127.0.0.1:5678 means only software on the server can reach n8n directly; internet traffic must pass through Caddy.

Put Caddy in front for HTTPS

Install Caddy from its official Debian repository:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Set /etc/caddy/Caddyfile to:

n8n.example.com {
    reverse_proxy 127.0.0.1:5678
}

Validate and reload it:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://n8n.example.com/

N8N_PROXY_HOPS=1 tells n8n there is one reverse proxy in front of it. WEBHOOK_URL gives integrations the public HTTPS base URL instead of an internal container address. Create the owner account immediately after opening the editor.

Apply a default-deny firewall without locking out SSH:

sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw default deny incoming
sudo ufw enable
sudo ufw status verbose

From another machine, confirm the direct n8n port is closed:

curl --max-time 5 http://YOUR_VPS_IP:5678/

A timeout or refusal is correct. An n8n response means the port binding or another proxy rule is exposing it.

Back up n8n before you update it

A useful backup contains the PostgreSQL dump and the .env file holding the encryption key. Store the final archive away from the VPS.

#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n
STAMP="$(date +%Y%m%d-%H%M%S)"
DEST="/var/backups/n8n/$STAMP"
mkdir -p "$DEST"

docker compose exec -T postgres \
  pg_dump -U n8n -d n8n --format=custom > "$DEST/n8n.dump"
install -m 600 .env "$DEST/n8n.env"
sha256sum "$DEST/n8n.dump" "$DEST/n8n.env" > "$DEST/SHA256SUMS"

Check that the dump is readable before trusting it:

docker compose exec -T postgres pg_restore --list \
  < /var/backups/n8n/DATE/n8n.dump | head

Then send the directory to object storage or another machine with your existing backup tool. A dump on the same disk protects against a bad application update, but not a deleted VPS or failed volume.

How to update n8n safely

n8n's update guidance says to update frequently, review release notes, and test first where possible. For this pinned Compose setup:

  1. Read the releases between your current and target versions.
  2. Run and verify the backup.
  3. Edit only N8N_VERSION in .env to the target stable version.
  4. Pull, recreate, and inspect the logs.
cd /opt/n8n
# after changing N8N_VERSION in .env
docker compose config --quiet
docker compose pull n8n n8n-runner
docker compose up -d n8n n8n-runner
docker compose ps
docker compose logs --tail=150 n8n n8n-runner
docker compose images n8n n8n-runner

Update the two images together because n8n requires the runner image version to match the main application. Save the docker compose images output with the backup timestamp so an incident review can identify the tags and image IDs that actually ran.

Test the editor, one manual workflow, one production webhook, and one scheduled workflow. Do not call a downgrade a rollback: application releases can run database migrations, and an older image may not understand the changed schema. A real rollback restores the pre-update database dump and returns the prior image version together.

Also keep the PostgreSQL major tag separate from an n8n update. Updating n8n from one stable application release to another does not require changing PostgreSQL 18.

What commonly goes wrong

The webhook URL shows localhost or returns 404

Check the public settings and proxy count:

docker compose exec n8n env | grep -E '^(WEBHOOK_URL|N8N_HOST|N8N_PROTOCOL|N8N_PROXY_HOPS)='

WEBHOOK_URL must be the public HTTPS URL with a trailing slash. Confirm Caddy serves that exact hostname and DNS points to the VPS.

PostgreSQL stays unhealthy

Read the database logs instead of repeatedly restarting the stack:

docker compose logs --tail=200 postgres
docker compose exec postgres pg_isready -U n8n -d n8n

A password changed after the volume was initialized does not rewrite the existing database user. Restore the original secret or deliberately change the password inside PostgreSQL.

n8n restarts or the server kills it

Check container state and the kernel log:

docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}}' n8n-n8n-1
sudo dmesg -T | grep -i -E 'out of memory|killed process' | tail

If it was OOM-killed, reduce payload sizes and workflow concurrency or resize the VPS. Swap can soften a brief spike, but it does not turn insufficient RAM into a healthy production setup.

The Compose file works but port 5678 is public

Inspect the actual published binding:

docker compose port n8n 5678
sudo ss -lntp | grep ':5678'

The result should show 127.0.0.1, not 0.0.0.0 or [::].

When self-hosting n8n is the wrong choice

Use n8n Cloud if nobody on your team owns Linux patching, database restore tests, TLS, monitoring, and incident response. The monthly hosting saving disappears quickly when a failed webhook can cost a client or when the only backup has never been restored.

This single-server design is also wrong for strict high availability or sustained heavy execution volume. n8n supports scaling patterns with workers and task runners, but those add Redis, coordination, and more failure modes. Measure queueing and execution load first rather than copying an enterprise diagram onto a small VPS.

For learning, SQLite and a local Docker container are faster. For regulated data, confirm the license, data residency, access controls, retention, and audit requirements with the responsible person before treating a VPS as compliance.

My recommendation

Use this PostgreSQL and Caddy layout for a small production instance, keep n8n pinned to a stable release, and schedule off-server backups before adding important workflows. A 4 GB VPS is a practical starting point, not a universal minimum. Watch memory, disk growth, execution duration, and failed webhooks for a week, then resize from evidence.

If you want to compare the actual monthly bill before buying a server, read my n8n self-hosting cost breakdown. If the VPS will carry more than n8n, the one-VPS multi-site guide explains the tradeoff between consolidating services and increasing blast radius.

FAQ

How do I install n8n on a VPS with Docker?

Install Docker and Compose, run n8n with PostgreSQL in a Compose stack, bind port 5678 to localhost, and use Caddy for public HTTPS. Persist both the database and /home/node/.n8n, then verify the public URL and confirm port 5678 is closed externally.

How much RAM does n8n need on a VPS?

Start with 4 GB for n8n, PostgreSQL, Caddy, and light production workflows on one server. The right number depends on payload size and concurrency, so check container memory and OOM logs instead of treating any fixed RAM figure as a guarantee.

Can I install n8n on a VPS without a domain?

You can test over localhost or an SSH tunnel, but a normal production setup needs a trusted HTTPS URL for browser access and external webhooks. A domain plus an automatically renewed Caddy certificate is the simpler long-term path.

Should n8n use SQLite or PostgreSQL?

SQLite is the default and is fine for a local trial. PostgreSQL is the better production default when you want established dump, restore, monitoring, and concurrent database tooling. Keep the .n8n volume even when PostgreSQL stores workflows and executions.

How do I update n8n running in Docker Compose?

Back up PostgreSQL and the encryption key, change the pinned N8N_VERSION, run docker compose pull n8n, and recreate the service with docker compose up -d n8n. Read release notes and test workflows; do not assume switching back to an older image reverses a database migration.

Why is n8n port 5678 still reachable through UFW?

Docker manages its own packet-filter rules, so a port published on every interface can bypass the UFW rules you expected to protect it. Publish 127.0.0.1:5678:5678 and expose only ports 80 and 443 through the reverse proxy.

Is a one-click n8n VPS template better than Docker Compose?

A maintained template can save setup time, but inspect its port binding, database, encryption key handling, update process, and backup path. A quick install is useful only if you can still explain how to restore it after the server or database fails.

The VPS I actually run this on

I use a KVM VPS for n8n because it leaves enough room for PostgreSQL, Caddy, backups, and other small Docker services on the same server.

Check current VPS pricing →
Taarush Kane

I run TKVibes, a digital agency. I self-host the automation stack that runs it — n8n, AI agents, and a pile of Docker containers. These are my notes.

Some links on this page are affiliate links. If you buy through them I earn a commission at no extra cost to you. I pay for the services I write about and only recommend what I actually use.