Self-host n8n on a VPS with Docker and Postgres

Self-host n8n on a VPS with Docker and Postgres

You hit the execution cap on the n8n Cloud plan, did the maths on the next tier, and decided a VPS is cheaper. Good instinct. The move itself is an evening of work, but two or three defaults will waste that evening if nobody warns you first. This walks the whole path on Ubuntu 24.04 LTS, pinned to n8n 2.41.4 (the stable release from 30 September 2026) and Postgres 16.

What is n8n?

n8n is a workflow automation tool. You wire together triggers (a webhook, a cron schedule, an incoming email) and actions (call an API, write a row, post to Slack) on a visual canvas, and it runs them for you. People reach for it as a self-hostable replacement for Zapier or Make, where you pay per task or per execution. Self-hosting removes that meter. You pay for the box and run as many executions as the box can handle.

The catch is that n8n out of the box stores everything in a SQLite file and assumes it is reachable at localhost:5678. Neither assumption survives contact with a real deployment behind a domain. So we fix both from the start.

Browser / API caller HTTPS 443 nginx TLS + proxy 127.0.0.1:5678 n8n container 5432 Postgres 16 The request path. Only nginx listens on the public interface; n8n binds to loopback and talks to Postgres on the Docker network.

What you need

n8n is light. A single main process plus Postgres is comfortable on 1 vCPU, 2 GB RAM, 20 GB disk. 1 GB will boot and run trivial workflows, but Postgres plus the n8n process plus a burst of a few concurrent executions will push a 1 GB box into swap, and the editor gets sluggish. Treat 2 GB as the honest floor and 4 GB as comfortable if you run image or PDF nodes. Disk grows with execution history; prune it (we do below).

You also need a domain or subdomain you control, and root or sudo. That is the full list. Any of Karizanta's Netherlands VPS in Naaldwijk plans runs this fine from the smallest tier up, and because it is peered into AMS-IX and DE-CIX, workflows that hammer EU-hosted APIs get short round trips. Deployment takes under 60 seconds and you can bump RAM later from the client area with one reboot, keeping your data and IP, so starting small costs you nothing.

ResourceMinimumWhy
RAM2 GBn8n + Postgres + execution bursts without swap
vCPU1Single main process until you add queue mode
Disk20 GBImage layers plus pruned execution history
OSUbuntu 24.04 LTSCurrent LTS, ships a recent Docker

Point the domain at the server

Create an A record for the hostname you will use, pointing at the VPS public IPv4, and an AAAA record for IPv6 if you have one. Example with the placeholder n8n.example.com:

n8n.example.com. A 203.0.113.10
n8n.example.com. AAAA 2001:db8::10

Check it has propagated before you ask for a certificate, or the issuance will fail:

dig +short n8n.example.com
# 203.0.113.10

Update the system and install Docker

sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl
curl -fsSL https://get.docker.com | sudo sh
sudo docker --version
# Docker version 27.x, build ...

The convenience script installs Docker Engine and the Compose v2 plugin. Add your user to the docker group so you drop the sudo on every command:

sudo usermod -aG docker $USER
newgrp docker

Create the directory layout and secrets

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

Generate two secrets. The encryption key protects stored credentials; if you lose it, every saved credential becomes unreadable. Pin it now so it survives container rebuilds.

cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 24)
EOF
chmod 600 .env

Write the Docker Compose file

This is where the SQLite default dies. We point n8n at Postgres, bind it to loopback so only nginx can reach it, and set every URL variable that a reverse proxy needs. Save as /opt/n8n/docker-compose.yml and change the four hostname lines to your domain and timezone:

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

n8n:
image: docker.n8n.io/n8nio/n8n:2.41.4
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
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_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: n8n.example.com
N8N_PORT: 5678
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.example.com/
N8N_EDITOR_BASE_URL: https://n8n.example.com/
GENERIC_TIMEZONE: Europe/London
TZ: Europe/London
N8N_PROXY_HOPS: 1
N8N_RUNNERS_ENABLED: "true"
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 336
volumes:
- n8n_data:/home/node/.n8n

volumes:
postgres_data:
n8n_data:

Two lines earn their keep here. WEBHOOK_URL is the one that fails silently if you forget it, and it is covered in its own trap below. N8N_PROXY_HOPS: 1 tells n8n to trust the X-Forwarded-For header from exactly one proxy in front of it, so rate limits and logs see the real client IP rather than nginx. EXECUTIONS_DATA_MAX_AGE: 336 prunes execution history older than 336 hours (14 days), which stops Postgres growing without bound.

Start it and write a boot unit

docker compose pull
docker compose up -d
docker compose ps

You should see both services up and Postgres healthy:

NAME IMAGE STATUS
n8n-n8n-1 docker.n8n.io/n8nio/n8n:2.41.4 Up 20 seconds
n8n-postgres-1 postgres:16 Up 25 seconds (healthy)

The restart: unless-stopped policy already brings the stack back after a reboot once the Docker daemon starts. If you want systemd to own the lifecycle explicitly (clean systemctl status, ordered shutdown), drop this unit at /etc/systemd/system/n8n.service:

[Unit]
Description=n8n (Docker Compose)
Requires=docker.service
After=docker.service network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/n8n
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now n8n.service

Put nginx in front and issue TLS

sudo apt -y install nginx
sudo tee /etc/nginx/sites-available/n8n >/dev/null <<'EOF'
server {
listen 80;
server_name n8n.example.com;
location / {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

The Upgrade and Connection headers are not optional. n8n uses a WebSocket push channel to update the canvas live; without them the editor loads but hangs on "Connection lost". The proxy_read_timeout 3600 keeps nginx from cutting off a long-running workflow at the default 60 seconds. Now the certificate:

sudo apt -y install certbot python3-certbot-nginx
sudo certbot --nginx -d n8n.example.com --redirect -m you@example.com --agree-tos
# Congratulations! ... Certificate deployed for n8n.example.com

Certbot rewrites the server block to listen on 443 and redirect 80, and installs a renewal timer.

Verify it works

Three checks, not a glance at the login page. Health endpoint over TLS:

curl -I https://n8n.example.com/healthz
# HTTP/2 200

Container actually up, not crash-looping:

docker compose exec n8n wget -qO- http://localhost:5678/healthz
# {"status":"ok"}

And confirm it bound Postgres, not SQLite, in the startup log:

docker compose logs n8n | grep -i 'migration\|sqlite\|postgres'
# no 'sqlite' lines; migrations run against postgresdb

First login

Open https://n8n.example.com. The first visit shows the owner account setup screen, where you create the admin email and password. n8n's built-in user management handles accounts from there, so you do not need HTTP basic auth in nginx on top. Create the owner, then add users under Settings if you have a team.

Management commands

# status and logs
docker compose ps
docker compose logs -f n8n

# stop / start
docker compose down
docker compose up -d

# back up the database and the n8n data volume
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > n8n-$(date +%F).sql.gz
docker run --rm -v n8n_n8n_data:/data -v $PWD:/backup alpine \
tar czf /backup/n8n-data-$(date +%F).tgz -C /data .

# upgrade: bump the tag in docker-compose.yml, then
docker compose pull && docker compose up -d

Back up both the Postgres dump and the n8n_data volume. The volume holds the encryption key and settings file; the database holds workflows, credentials and history. You need both to restore. Scheduled off-site backups are available as a paid add-on if you would rather not cron this yourself.

Production hardening

Close everything except SSH and web at the firewall. n8n is already on loopback, so only 80 and 443 should be public:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
# 22/tcp, 80/tcp, 443/tcp ALLOW

The container already runs as the unprivileged node user, and N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true refuses to start if the settings file is world-readable. Add fail2ban for SSH, keep the DDoS filtering that ships on every plan doing its job at the edge, and monitor three things: disk (execution history and image layers fill it), the /healthz endpoint, and Postgres connection count. If you want the SSH and firewall baseline step by step, our guide to securing a VPS with a firewall, SSH and fail2ban covers it.

Where this can bite you

Webhooks register as localhost. This is the big one. With no WEBHOOK_URL set, n8n builds webhook URLs from the address it binds to, so the Production URL shown in a Webhook node reads http://localhost:5678/webhook/.... The external service you paste that into can never reach it, and nothing errors; the workflow just never fires. Set WEBHOOK_URL to your public https:// URL (we did, above), restart, and reopen the node to confirm the URL now shows your domain. If you change the domain later, you must restart n8n for registered webhook URLs to update.

Login works over the domain but not over the IP. By default n8n sets a secure cookie, so if you test by hitting http://<ip>:5678 directly you get kicked back to the login screen in a loop. That is expected once you are behind TLS. Reach it through https://n8n.example.com. Only if you genuinely need plain-HTTP access should you set N8N_SECURE_COOKIE=false, and then only temporarily.

Schedules fire at the wrong hour. A Schedule Trigger uses GENERIC_TIMEZONE, which defaults to America/New_York. If you left it at the default, a "run at 09:00" job in London fires at 14:00. Set both GENERIC_TIMEZONE and TZ to your zone, restart, and recheck any existing schedules, because the stored cron is interpreted against the new zone.

Queue mode is needed sooner than you think. The single-process default runs every execution inside the main instance. One slow API call, a big loop, or a handful of concurrent webhooks and the editor stalls while executions queue behind each other. When that starts happening, switch to queue mode: add Redis, set EXECUTIONS_MODE=queue plus QUEUE_BULL_REDIS_HOST, and run one or more n8n worker containers alongside the main one. All processes must share the same N8N_ENCRYPTION_KEY, and note that filesystem binary-data storage is unsupported in queue mode, so move binary data to S3 first. Plan for this at the design stage rather than the day it falls over.

Where to run this

n8n is CPU-light until queue mode, so the smallest tier of any region works for a single-user setup. Pick on latency and data location. If most of your workflows call EU-based SaaS APIs, the Netherlands VPS in Naaldwijk and its AMS-IX and DE-CIX peering give the shortest hops. For data that should stay in the Nordics, the Sweden VPS in Stockholm sits in the GleSYS facility. For UK residency, UK VPS in Coventry runs on our own hardware. Start at 2 GB; when you move to queue mode with Redis and a worker, bump RAM from the client area with one reboot, no rebuild. If you are hardening a fresh box first, the initial VPS setup checklist pairs well with this guide.

Sources

  1. n8n docs: Install with Docker (unknown)
  2. n8n docs: Enable queue mode (unknown)
  3. n8n releases (v2.41.4, 30 Sep 2026) (2026-09)
  4. n8n Self-Hosting Guide: Docker, PostgreSQL, HTTPS, Backups and Queue Mode (2026)

Frequently asked questions

Why do my n8n webhooks point to localhost after self-hosting?

Because WEBHOOK_URL is unset. n8n builds webhook URLs from the address it binds to, which is localhost:5678 behind a proxy. Set WEBHOOK_URL to your public https URL, restart n8n, and reopen the Webhook node to confirm the Production URL now shows your domain.

Do I need Postgres, or is the default SQLite fine?

SQLite works for a quick test but locks under concurrent executions and is awkward to back up live. Use Postgres from the start by setting DB_TYPE=postgresdb and the DB_POSTGRESDB_* variables. Queue mode later also assumes a real database behind it.

How much RAM does self-hosted n8n need on a VPS?

2 GB is the honest floor for n8n plus Postgres on one box. 1 GB boots and runs trivial workflows but drops into swap under any burst. Use 4 GB if you run image or PDF nodes, and add RAM later when you switch to queue mode.

When should I switch n8n to queue mode?

When executions start blocking each other: slow API calls, large loops, or several concurrent webhooks stalling the editor. Queue mode adds Redis and separate worker processes. All processes must share the same N8N_ENCRYPTION_KEY, and binary data must move to S3 since filesystem storage is unsupported in queue mode.

Why do my scheduled workflows run at the wrong time?

GENERIC_TIMEZONE defaults to America/New_York. A Schedule Trigger interprets its cron against that zone unless you override it. Set both GENERIC_TIMEZONE and TZ to your own timezone, restart n8n, and recheck existing schedules because the stored cron is re-evaluated against the new zone.

Related reading

Tutorials

WordPress on a UK VPS: a speed and security checklist

A command-level checklist for migrating a small agency's WordPress site off shared hosting onto a UK VPS you now run yourself: PHP-FPM sized from RAM, OPcache and Redis, a page cache that never leaks a cart, TLS and HTTP/2, the security layer that carries real weight, and keeping personal data under UK GDPR.

Read more →
Deploy your server ← Back to blog