How to Install n8n on a VPS with Docker in 2026
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
| Choice | Good for | Main drawback | My verdict |
|---|---|---|---|
| n8n + SQLite in one container | Local trial | Weak fit for concurrent production work and harder database operations | Use only to learn |
| n8n + PostgreSQL + runner + Caddy | Small production VPS | You own updates and backups | Best default |
| n8n Cloud | No server maintenance | Recurring managed-service cost and plan limits | Buy when your time matters more |
| Queue mode with workers | High execution volume | Redis, workers, and more operational work | Add 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:
- Read the releases between your current and target versions.
- Run and verify the backup.
- Edit only
N8N_VERSIONin.envto the target stable version. - 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 →Read next
- VPS vs Shared Hosting: Which Should You Choose in 2026?
- Atmospheric Water Generators: The Physics, the Yield, and the Real Cost Per Litre
- What Off-Grid Power Actually Costs Per Month (2026 Breakdown)
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.