The n8n VPS Setup We Use for Small Teams
The exact Hetzner VPS spec, docker-compose file, Caddy SSL config, pg_dump backup cron, and monitoring setup we deploy for small teams running n8n in production.
Most guides to self-hosting n8n stop at the moment the login screen loads. That is roughly ten percent of the work. The other ninety percent is the quiet part: picking a VPS that does not fall over on the third concurrent workflow, putting a database behind n8n so you stop losing executions, terminating TLS without babysitting certbot, getting backups off the box before the box dies, and knowing within five minutes when something breaks. This is the reference setup we deploy for small teams at WitsCode, written out in full. You can copy it end to end.
The audience is a founder or operator with three to ten active workflows, fewer than five thousand executions a day, maybe two or three teammates who edit flows, and a strong preference for paying infrastructure bills measured in single-digit dollars rather than hundreds. If you are past that scale you still want most of what follows, you just want it harder.
Why self-host n8n at all
The honest answer for small teams is money, then control, in that order. n8n Cloud starts around twenty dollars a month for the Starter plan and climbs to fifty for Pro, with execution caps that look generous until a single polling workflow eats through them. A self-hosted instance on a five dollar VPS runs unlimited executions against your own database. For a team running even a handful of integrations that hit third-party APIs every few minutes, the payback window on the setup work described below is usually the second month.
Control is the sweeter part. You own the database, which means you can query execution history directly, back it up to wherever you like, and migrate without asking anyone. You set the version you run. You decide when to upgrade. Credentials live on your server, encrypted with a key you control, which matters more every quarter as procurement questions get sharper.
The tradeoff is that you are now running a server. Not a scary one, but a real one, with a real uptime expectation. The rest of this article exists to make that boring.
The VPS spec we settle on
We default to Hetzner Cloud, specifically the CX22 instance in the Falkenstein, Nuremberg, or Ashburn datacenter depending on where the team sits. The spec is two vCPUs, four gigabytes of RAM, forty gigabytes of NVMe, twenty terabytes of outbound transfer, and it costs around five dollars and forty cents per month at current pricing. There is a cheaper CX11 tier, but do not use it for n8n in production. Here is the math. A fresh n8n main process idles around three hundred megabytes. Postgres 16 with the default config and a small n8n database sits at another three hundred. A single running workflow execution typically adds between one hundred and four hundred megabytes depending on how much data it is passing around. Caddy is negligible. Add the OS overhead and you are at roughly one point eight gigabytes baseline, and you want comfortable headroom for the moment three executions fire simultaneously and one of them is processing a fat Airtable response. Two gigabytes of RAM will swap and slow everything down. Four gigabytes will not.
We have tried DigitalOcean, Vultr, Linode, and OVH for the same workload. They all work. DigitalOcean is the most polished experience and costs roughly twice as much for the equivalent spec. Vultr is competitive on price but we have seen more noisy-neighbor variance. Hetzner wins on price-to-performance by a wide margin and the reliability has been excellent across the fleet we manage.
The base OS is Ubuntu 24.04 LTS. First-login hardening is a short list. Create a non-root user with sudo. Disable password SSH and root login in /etc/ssh/sshd_config. Enable the uncomplicated firewall with ufw allow OpenSSH, ufw allow 80, ufw allow 443, then ufw enable. Install unattended-upgrades so security patches land without you. Install Docker and the compose plugin from the official Docker apt repo, not the distro version, which lags. Add your user to the docker group so you are not typing sudo for every command.
The docker-compose file
Everything runs in Docker. One compose file, three services: Postgres for state, n8n for the application, Caddy for TLS and reverse proxy. We put it all in /opt/n8n on the host.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
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_HOST: ${N8N_HOST}
N8N_PROTOCOL: https
N8N_PORT: 5678
WEBHOOK_URL: https://${N8N_HOST}/
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
GENERIC_TIMEZONE: ${TZ}
TZ: ${TZ}
N8N_RUNNERS_ENABLED: "true"
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
volumes:
- ./n8n-data:/home/node/.n8n
- ./n8n-files:/files
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
A few notes on choices. We pin Postgres to a major version (16) and let the patch version float. We do not pin n8n to latest in production for customers; we move it to a specific tag like 1.75.2 once we are confident in an upgrade. The N8N_RUNNERS_ENABLED flag is important on any n8n release after 1.69; it moves code execution into separate runner processes, which is both safer and faster. EXECUTIONS_DATA_PRUNE set to true with a max age of 336 hours keeps fourteen days of execution history, which is plenty for debugging and keeps the database lean. Without pruning, Postgres grows indefinitely and you will be surprised at how fast.
The .env file next to the compose file holds the secrets. Generate the encryption key once with openssl rand -hex 32 and never change it; losing it means every saved credential becomes unusable.
POSTGRES_USER=n8n
POSTGRES_PASSWORD=replace-with-long-random-string
POSTGRES_DB=n8n
N8N_HOST=n8n.yourdomain.com
N8N_ENCRYPTION_KEY=replace-with-openssl-rand-hex-32
TZ=Europe/Berlin
Set the file permissions to 600 so only your user can read it. Back this file up separately, once, to a password manager. If the VPS burns down you need the encryption key to restore credentials from a database backup.
SSL and domain with Caddy
The single best decision in this stack is using Caddy instead of nginx plus certbot. Caddy requests and renews Let's Encrypt certificates automatically, handles redirection from HTTP to HTTPS, and fits the entire config for this use case into six lines. No cron jobs for renewals. No silent failures two months in. Point an A record from n8n.yourdomain.com to the VPS IP, wait for DNS to propagate, then drop this into /opt/n8n/Caddyfile:
n8n.yourdomain.com {
reverse_proxy n8n:5678
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
}
}
docker compose up -d and within about thirty seconds Caddy has pulled a certificate and the site is live on HTTPS. If it does not, the Caddy logs will say why, usually a DNS record that has not propagated or port 80 being blocked by the firewall. Certs renew on their own from here forever.
Backups to Backblaze B2
A server without off-box backups is a server waiting to teach you a lesson. The database holds everything that matters: workflows, credentials, execution history, user accounts. We dump it daily and ship the dump to S3-compatible object storage. After pricing every option we use Backblaze B2. Storage is six dollars per terabyte per month. Equivalent space on AWS S3 standard is roughly twenty three dollars. For n8n backups where you are storing tens of gigabytes at most, the monthly bill is measured in cents, but the pattern scales if you decide to keep other state there too.
Create a B2 bucket called something like n8n-backups-yourcompany, generate an application key with write access to just that bucket, install rclone or the b2 CLI on the host. We use rclone because it handles multiple providers if you ever move. Configure it once with rclone config under a remote name like b2.
Then this shell script at /opt/n8n/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/n8n
TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP_FILE="n8n-${TIMESTAMP}.sql.gz"
docker compose exec -T postgres pg_dump \
-U "${POSTGRES_USER:-n8n}" \
-d "${POSTGRES_DB:-n8n}" \
--no-owner --clean --if-exists \
| gzip -9 > "/tmp/${BACKUP_FILE}"
rclone copy "/tmp/${BACKUP_FILE}" "b2:n8n-backups-yourcompany/daily/"
rm "/tmp/${BACKUP_FILE}"
rclone delete --min-age 30d "b2:n8n-backups-yourcompany/daily/"
Make it executable. The last line keeps thirty days of daily backups and removes older ones, which is usually the right tradeoff between restore flexibility and storage cost. The script reads the Postgres credentials from the shell environment; in the cron line below we source the .env file first.
Then a crontab entry under your non-root user:
15 3 * * * set -a && . /opt/n8n/.env && set +a && /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1
That runs at 03:15 UTC every day. Log to a file and check it for the first week, then forget about it. Once a quarter, test a restore into a local Docker environment to confirm the backups actually work. An untested backup is not a backup.
Monitoring with Uptime Kuma
The failure mode you want to avoid is realizing on a Wednesday afternoon that n8n stopped running sometime Monday night and no workflows have fired in thirty six hours. Two layers of monitoring handle this cheaply. The first is external uptime checking, which pings the site from outside your VPS. The second is a heartbeat from inside n8n itself, which proves not just that the web interface is up but that the scheduler is actually executing workflows.
We run Uptime Kuma on a separate tiny VPS, or on a spare corner of another server, never on the same box as n8n (the point is to notice when that box dies). It is a single Docker container, takes ten minutes to set up, and handles both jobs. Configure one HTTP monitor pointing at https://n8n.yourdomain.com to catch the box or Caddy going down. Configure a push monitor, which gives you a unique URL that expects to be hit on an interval. Then inside n8n create a workflow with a schedule trigger set to every five minutes and a single HTTP request node that hits the push URL. If the scheduler stops, or the workflow engine wedges, Uptime Kuma stops getting pings and fires an alert.
Route alerts to wherever the on-call person actually looks. Telegram and email are the usual pair. Slack works if the team lives there. The important bit is not the channel, it is the five-minute resolution, which is the difference between finding out about a problem in real time and finding out from a customer.
Update cadence
n8n ships a release roughly weekly. Chasing every release is a recipe for breaking production on a Friday. Ignoring releases for six months is a recipe for a painful migration when you finally have to. We split the difference. First Monday of every month is update day. The procedure is three commands with coffee.
cd /opt/n8n
docker compose pull
docker compose up -d
Before running this, read the release notes for every version between what you are on and what is current. n8n occasionally introduces breaking changes to specific nodes; the release notes call these out clearly and they are almost always minor. For customer environments we also take a manual pg_dump immediately before, which gives us a point-in-time backup labeled with the version we are coming from. If something is broken after the upgrade, rollback is changing the image tag in the compose file back to the prior version, docker compose up -d, and restoring the pre-upgrade database dump if the schema changed.
Twice a year we also do OS-level maintenance: apt update && apt full-upgrade, reboot, confirm everything comes back. Unattended upgrades handle security patches daily, but the kernel updates queue up until a reboot and it is cleaner to do them on a schedule than to have the machine reboot itself at an unpredictable moment.
When to hand this to someone else >
The setup above runs beautifully for most small teams. It is also, once built, almost entirely hands-off. The scenarios where teams outgrow it are specific. You start needing SAML or SSO for team access, which means moving to n8n Enterprise or building a bastion. You are running sensitive enough workflows that you want a formal SLA, audit logs shipped to a SIEM, and on-call coverage that is not one of your engineers checking their phone on a weekend. You cross into the execution volume where a single CX22 starts queuing and you need to split into a multi-worker queue-mode deployment with Redis and separate webhook processors. Or, most commonly, your team simply has better things to do than carry this on top of their actual job.
That is the point at which WitsCode takes over the deploy. We run the same reference stack, with the same Caddy, the same Postgres, the same backup shape, but on infrastructure we monitor twenty-four seven, with upgrade handling, restore drills, and the on-call rotation attached. If you are already past the point where this guide stops feeling like a fun Saturday project and starts feeling like a liability, that is the conversation worth having. Until then, the configs above are the entire thing. Copy them, adjust the domains, and you have the same production n8n we run for ourselves.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Custom Web Applications
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss non-tech founders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.