How to Deploy n8n on a VPS
Updated Jul 2026
Self-host n8n on your own VPS — a fair-code Zapier/Make alternative for workflow automation, running on Docker with Postgres and HTTPS.
- A VPS with 1–2 vCPU / 2 GB RAM (n8n plus a Postgres database)
- A fresh Ubuntu 24.04 server with root/sudo SSH access, Docker + Compose installed
- A domain you can point at the server — webhooks and OAuth callbacks need a public HTTPS URL
What n8n is
n8n is a fair-code, self-hostable workflow-automation tool — think Zapier or Make, but running on a box you own. You build automations in a visual node editor: a trigger fires (a webhook, a schedule, an app event), data flows through a chain of nodes, and each node transforms it, calls an API, or writes it somewhere. There are 400+ built-in integrations, and when none fits you drop in a Code node and write JavaScript or Python directly.
The appeal is the same one that draws people to any self-hosted tool: cost and control. Zapier and Make bill per task or per operation, and a busy automation that runs thousands of times a month gets expensive fast. A single VPS running n8n costs the same whether it fires a hundred workflows a day or a hundred thousand — you're bounded by the box, not a per-task meter. You also keep your data and credentials on infrastructure you own, which matters when your workflows touch internal systems or customer records.
One thing to get right before you start: n8n really wants a real domain and public HTTPS. Many integrations rely on OAuth callbacks, and inbound webhooks have to be reachable from the internet. That's the single biggest reason a VPS beats a home lab for n8n — a stable public IP and a domain you control, rather than fighting NAT and dynamic addresses. This guide covers the production setup: Docker Compose, Postgres as the datastore, and a reverse proxy for TLS.
Server sizing — why 2 GB is plenty
n8n itself is light. The core process is a single Node service, and unless you're running heavy parallel workflows it sits mostly idle, waking to execute a workflow and going back to sleep. 1–2 vCPU and 2 GB RAM comfortably runs a solo or small-team instance plus its Postgres database, with headroom for the occasional memory-hungry workflow that pulls a large dataset through a node.
This makes n8n one of the cheapest useful things you can self-host. A small box is the right start — you do not need to over-provision. Where it gets heavier is high-volume parallel execution: if you're running many workflows concurrently, n8n supports a queue mode that offloads executions to separate worker containers backed by Redis, so you can scale horizontally by adding workers. That's a real capability, but it's beyond a first deploy and you'll know when you need it (executions backing up, the main process pegged). Start on a single small box in the default execution mode; reach for queue mode later if throughput demands it.
For the initial deploy, a Hetzner CX22 (2 vCPU / 4 GB) has ample room, or the smallest tier that gives you 2 GB is enough. Give the disk a little thought too: execution data accumulates in Postgres over time, so 20–40 GB is comfortable, and you can prune old execution history to keep it in check.
Prepare the server
This guide assumes you've already worked through
Docker & Compose on Ubuntu — a fresh
Ubuntu 24.04 box, a non-root deploy user, Docker Engine and the Compose plugin
installed, and ufw allowing SSH plus 80 and 443. If you haven't, do that first;
everything below is a compose file on top of that base.
n8n listens on port 5678 by default, but you should not open that port to the internet. A reverse proxy will terminate TLS on 443 and forward to n8n over the internal Docker network, so 5678 stays private. With the base firewall from the Docker guide (SSH, 80, 443), you already have exactly the right ports open and nothing more.
Create a working directory for the stack:
mkdir ~/n8n && cd ~/n8n
Install n8n
n8n runs as two containers: the official n8nio/n8n image, and a Postgres
database. n8n defaults to SQLite, which is fine for a quick trial, but Postgres
is the production-recommended datastore — SQLite serializes writes and struggles
under concurrent executions and larger volumes of execution data, whereas Postgres
handles concurrency and stays reliable as your history grows. Set it up on Postgres
from the start; migrating later is more work than doing it right once.
First, generate an encryption key and a database password, and write them to a
.env file the compose stack will read:
cat > .env <<EOF
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 .env
Back up that .env file somewhere safe right now. The N8N_ENCRYPTION_KEY is
the most important secret in the whole setup — see the warning below. Then create
the compose file:
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: n8nio/n8n:latest
restart: unless-stopped
ports:
- "127.0.0.1:5678:5678"
depends_on:
postgres:
condition: service_healthy
environment:
# --- database ---
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
# --- credential encryption ---
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
# --- public address (edit to your domain) ---
N8N_HOST: n8n.example.com
N8N_PROTOCOL: https
N8N_PORT: 5678
WEBHOOK_URL: https://n8n.example.com/
# --- scheduling ---
GENERIC_TIMEZONE: Europe/Berlin
TZ: Europe/Berlin
volumes:
- n8n_data:/home/node/.n8n
volumes:
postgres_data:
n8n_data:
A few things worth understanding before you bring it up:
ports: "127.0.0.1:5678:5678"binds n8n only to localhost, so it's reachable by the reverse proxy on the same box but never directly from the internet. Note that Docker can bypassufwfor published ports, so this explicit127.0.0.1bind — not the firewall — is what keeps 5678 private.- The
n8n_datavolume (/home/node/.n8n) holds n8n's config and internal state. Combined with Postgres, these two volumes are your entire installation. GENERIC_TIMEZONEandTZset the timezone n8n uses for cron/schedule triggers. Get this wrong and your scheduled workflows fire at the wrong hour — set both to your actual timezone.
Edit the domain and timezone values, then start the stack:
docker compose up -d
docker compose logs -f n8n
On first start n8n runs its database migrations against Postgres automatically, so the first boot takes a little longer. Watch the log until it reports the editor is ready on port 5678.
The encryption key — read this
N8N_ENCRYPTION_KEY encrypts every credential you save in n8n (API keys, OAuth
tokens, database passwords) at rest. If you don't set it, n8n generates one on
first run and stores it inside the .n8n volume — which works, but means the key
lives only in one place. Set it explicitly, as above, so it's in your .env and
your backups.
If you ever lose this key, every stored credential becomes unrecoverable. n8n won't be able to decrypt them, and you'll have to delete and re-enter every credential by hand. Treat the key like a root password: back it up, and never change it on an existing instance unless you're deliberately resetting all credentials.
HTTPS + domain
n8n needs to be served over HTTPS on a real domain — this is not optional. OAuth providers refuse to redirect to a plain-HTTP or IP-based callback, and external services that call your webhooks expect a proper TLS endpoint. Put a reverse proxy in front to terminate TLS and forward to n8n on 5678.
The simplest path is Automatic HTTPS with Caddy,
which fetches and renews Let's Encrypt certificates for you. Point an A record
for your hostname (say n8n.example.com) at the server's public IP, then have
Caddy reverse-proxy that hostname to 127.0.0.1:5678. Once DNS resolves, Caddy
issues a certificate automatically and n8n is live on HTTPS.
The WEBHOOK_URL gotcha — the #1 self-host mistake. n8n needs to know its own
public address to generate the correct webhook and OAuth-callback URLs. That's what
N8N_HOST, N8N_PROTOCOL=https, and especially WEBHOOK_URL are for. If these
don't match the domain your proxy actually serves, n8n will hand out webhook URLs
pointing at localhost:5678 or the wrong scheme — and every inbound webhook and
OAuth flow breaks. Set WEBHOOK_URL to your full public origin
(https://n8n.example.com/), matching your proxy exactly, and restart the stack
after any change:
docker compose up -d
First-run setup
Load https://n8n.example.com in your browser. On a fresh install n8n shows an
owner-account setup screen.
Create the owner account immediately. Like most self-hosted tools, the setup screen is open until the first account exists — whoever registers first owns the instance. Set your email and a strong password before you do anything else.
Once you're in, build a first workflow to confirm everything works end to end:
- Create a new workflow and add a Manual Trigger (or a Schedule Trigger to verify your timezone is right).
- Add a second node — an HTTP Request to a public API is a good smoke test — and connect it to the trigger.
- Click Execute Workflow and check the data flows through. Green nodes mean success.
- To test webhooks specifically, add a Webhook node and confirm the Production
URL it shows starts with your real
https://n8n.example.com/— if it does, yourWEBHOOK_URLis correct. Call that URL from outside (e.g.curlfrom your laptop) and watch the execution fire.
If the webhook URL looks right and an external call triggers a run, your public addressing is correct and you're ready to build real automations.
Backups
Two things must be backed up, and both matter:
- The Postgres database. This holds your workflows, credentials (encrypted), and execution history — the actual content of your n8n instance.
- The n8n data volume (
/home/node/.n8n). This holds config and internal state, and if you let n8n auto-generate the encryption key, it lives here.
Losing either one hurts. And critically: a Postgres backup is useless without the
encryption key. The credentials in the database are encrypted with
N8N_ENCRYPTION_KEY — restore the database to a new instance with a different key
and every credential is unreadable. So your backup set is really three things: the
database dump, the .n8n volume, and the .env file holding the key.
Dump the database on a schedule and copy it, plus the .env, to off-box storage:
docker compose exec -T postgres pg_dump -U n8n n8n | gzip > n8n-db-$(date +%F).sql.gz
A backup that only lives on the same server dies with the server — get these files to an S3-compatible bucket or another machine, and periodically test a restore into a throwaway stack. An untested backup is a guess.
Upgrades
New n8n versions land frequently. To upgrade, pull the newer image and recreate the container:
docker compose pull
docker compose up -d
On start, n8n runs any pending database migrations automatically — which is why a current backup before upgrading is non-negotiable, since a migration changes your data in place.
Two habits make upgrades safe. First, pin a specific image tag rather than
riding latest, so you control exactly when you move and can reproduce the running
version — change n8nio/n8n:latest to a pinned tag like n8nio/n8n:1.70.0 and bump
it deliberately. Second, read the release notes before a major bump. n8n
occasionally ships breaking changes to nodes or behavior, and the notes tell you
what to check. Back up, read, then upgrade.
Troubleshooting
Webhooks return 404, or a service reports "the URL is not reachable." Almost
always a wrong WEBHOOK_URL. If n8n thinks its address is localhost:5678 but the
world reaches it at https://n8n.example.com, the URLs it generates point nowhere.
Confirm WEBHOOK_URL, N8N_HOST, and N8N_PROTOCOL=https all match your public
domain, restart, and re-check the Production URL shown on a Webhook node.
OAuth "redirect URI mismatch." The callback URL n8n generates (derived from your
public address) must be registered exactly in the third-party app's OAuth settings —
scheme, host, and path all have to match. Copy the OAuth redirect URL from n8n's
credential screen and paste it verbatim into the provider; don't hand-type it. A
wrong WEBHOOK_URL/N8N_HOST will also produce this, since the callback is built
from them.
Saved credentials suddenly fail to decrypt. The N8N_ENCRYPTION_KEY changed or
was lost — a different key can't decrypt credentials encrypted with the old one.
Restore the original key from your backup. If it's truly gone, the credentials are
unrecoverable and must be re-entered. This is why the key belongs in your backups.
Scheduled workflows fire at the wrong time. The container timezone is wrong. Set
both GENERIC_TIMEZONE and TZ to your actual timezone and restart.
Container won't start / database connection errors on boot. Usually n8n started
before Postgres was ready, or the DB credentials don't match. The depends_on
healthcheck in the compose file handles ordering; if you edited it, confirm Postgres
is healthy (docker compose ps) and that DB_POSTGRESDB_PASSWORD matches
POSTGRES_PASSWORD.
Verification + next steps
You're done when you can: load the n8n editor over HTTPS on your own domain, sign in
as the owner account, execute a workflow successfully, trigger a Webhook node from
outside the server, and see a database dump plus your .env landing in off-box
storage.
From here, connect the integrations your automations need, and prune old execution data periodically so Postgres stays lean. If throughput ever outgrows a single process, look into queue mode with Redis workers — but most instances never need it, which is exactly what makes n8n such a good fit for a small, cheap box. A Hetzner CX22 is the value pick; reach for Kamatera when you want to dial RAM precisely or scale on demand. See Best VPS for self-hosting for the ranked picks.