I moved an n8n instance off a managed cloud service about eight months ago. The paid tier was costing around $50/month for workflows that ran maybe 200 times a day. Nothing heavy. A Hetzner CX21 at 4.19 euros/month handles the same load comfortably, with room to spare.
This is what I learned doing that migration, and a few things I got wrong the first time.
Why Self-Host in the First Place
The honest answer is money. n8n Cloud is priced around execution volume, and once you have real automations running on real schedules, the math gets uncomfortable fast. You also get no control over the runtime environment, no ability to install custom nodes without a higher plan, and zero visibility into what happens when a workflow times out.
Self-hosting trades that monthly fee for your own time and a cheap VPS bill. Whether that trade makes sense depends on how much you hate downtime versus how much you hate paying recurring fees.
Picking a VPS That Won't Embarrass You
For a typical n8n setup running 10-20 active workflows, you do not need much.
What actually matters:
- At least 2 GB RAM. n8n's Node.js process idles around 200-300 MB but spikes during execution, especially with large data payloads. 1 GB boxes tend to OOM-kill the process at the worst time.
- SSD storage. The SQLite database (default) does fine on any SSD. If you plan to switch to PostgreSQL, it matters more.
- A European or Asian datacenter if your workflows hit APIs with rate limits by origin. Latency is not usually the bottleneck, but it affects webhook response times.
Providers worth considering in 2025:
| Provider | Plan | RAM | Storage | Price (approx) |
|---|---|---|---|---|
| Hetzner | CX21 | 4 GB | 40 GB SSD | ~€4.20/mo |
| Contabo | VPS S SSD | 8 GB | 50 GB NVMe | ~€5.99/mo |
| DigitalOcean | Basic Droplet | 2 GB | 50 GB SSD | ~$12/mo |
| Vultr | Regular | 2 GB | 55 GB NVMe | ~$12/mo |
Hetzner is the default recommendation in most communities right now, mostly because of the price-to-spec ratio in Germany and Finland. Contabo is cheaper if you can tolerate slower support and occasional network jitter.
The Setup: Docker Compose or Bare Node?
I prefer Docker Compose. Not because it is technically superior for this use case, but because it makes upgrades and rollbacks a single command.
Here is the setup I run in production:
yaml
version: '3.8'
services:
n8n:
image: n8nio/n8n:latest
restart: unless-stopped
ports:
- "5678:5678"
environment:
- N8N_HOST=your-domain.com
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_ENCRYPTION_KEY=your-random-key-here
- WEBHOOK_URL=https://your-domain.com/
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=your-db-password
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=168
volumes:
- n8n_data:/home/node/.n8n
depends_on:
- postgres
postgres:
image: postgres:15
restart: unless-stopped
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=your-db-password
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
n8n_data:
postgres_data:
A few things worth explaining:
EXECUTIONS_DATA_PRUNE=true with EXECUTIONS_DATA_MAX_AGE=168 keeps execution history to 7 days. Without this, the database grows without bound and eventually slows everything down. On a small VPS, this matters.
N8N_ENCRYPTION_KEY protects credentials at rest. Generate something random and store it somewhere safe. If you lose it, all saved credentials become unreadable.
Switch to PostgreSQL from SQLite early if you plan to scale or run multiple workers. SQLite works fine for low-volume setups, but migrating later is annoying.
Reverse Proxy: Nginx or Caddy
You need HTTPS, and you need a reverse proxy in front of n8n to handle it. Two reasonable options:
Caddy is simpler. It handles SSL via Let's Encrypt automatically and the config is minimal:
your-domain.com {
reverse_proxy localhost:5678
}
That is the entire config. It does TLS, handles renewals, and works with wildcard certs if you configure DNS challenge.
Nginx gives you more control if you need custom headers, rate limiting, or want to run multiple services on the same server:
nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
location / {
proxy_pass http://localhost: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-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
client_max_body_size 100M;
}
}
The client_max_body_size 100M line matters. n8n workflows that process files or large JSON payloads will fail silently on the default Nginx limit (1MB) and it takes a while to diagnose.
Memory: The Thing That Will Kill You
The single biggest issue with budget VPS hosting for n8n is memory pressure. Node.js will use what it can get, and when there is nothing left, the OS kills the process.
Three things help:
1. Add swap. Most VPS providers give you no swap by default. 2 GB of swap on an SSD gives you a buffer:
bash
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
SSD-backed swap is slow compared to RAM, but it keeps the process alive instead of crashing it.
2. Set Node.js memory limits. Add this to the n8n service environment:
yaml
- NODE_OPTIONS=--max-old-space-size=512
This caps V8's heap at 512 MB per process. It will throw errors for genuinely large workloads, but those errors are catchable and debuggable. OOM kills are neither.
3. Monitor it. Set up a cron job or a simple uptime monitor. n8n has a built-in health endpoint at /healthz. Ping it from an external service like UptimeRobot (free tier works) so you know when the process dies before your client does.
Scaling Without Buying More Servers
At some point the single-process setup hits a ceiling. n8n supports a queue mode that distributes execution across worker processes. You can run multiple workers on the same server (vertical scaling) or spread them across cheap VPS boxes (horizontal scaling).
To enable queue mode, you need Redis and a separate worker container:
yaml
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
n8n-worker:
image: n8nio/n8n:latest
restart: unless-stopped
command: worker
environment:
- QUEUE_BULL_REDIS_HOST=redis
- DB_TYPE=postgresdb
# ... same DB config as main service
depends_on:
- postgres
- redis
And on the main n8n service:
yaml
environment:
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
Two workers on a 4 GB VPS can handle a decent amount of parallel execution. Add a third box later if you need it. Each worker is stateless and picks jobs from Redis, so horizontal scaling is mostly a matter of spinning up another VPS and pointing it at the same database and Redis.
Backups: The Part Everyone Skips
If you run this without backups and the VPS provider has an incident, you lose everything. Workflows, credentials, execution history.
The minimum viable backup is a daily pg_dump piped to an S3-compatible bucket. AWS S3, Cloudflare R2, or Backblaze B2 all work:
bash
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
docker exec postgres pg_dump -U n8n n8n | gzip > /tmp/n8n_backup_$DATE.sql.gz
aws s3 cp /tmp/n8n_backup_$DATE.sql.gz s3://your-bucket/n8n-backups/
rm /tmp/n8n_backup_$DATE.sql.gz
Put this in a cron job at 3am. Cloudflare R2 has a free tier that easily covers a few months of compressed database dumps.
What This Actually Costs
A realistic production setup for a small team or indie developer, handling 1000-5000 workflow executions per day:
- Hetzner CX21 (4 GB RAM): ~€4.20/month
- Cloudflare R2 storage for backups: free tier (covers up to 10 GB)
- UptimeRobot monitoring: free
- Domain (if you don't have one): ~$10/year
Total: under €6/month running, maybe €10/month once you account for the domain.
n8n Cloud starts at $20/month for the Starter plan, capped at 2500 executions. If you are running more than that, the savings add up quickly.
Things I Got Wrong the First Time
I switched to PostgreSQL three months in, after the SQLite database grew to 800 MB and query times got noticeably slow. Should have started with Postgres.
I also did not set EXECUTIONS_DATA_PRUNE early enough. The execution history table had millions of rows by the time I noticed. Running the prune on a live instance locked the table for about 40 minutes.
The last one: I put the n8n instance on port 443 directly without a reverse proxy for the first week because I wanted to skip the setup. When I eventually added Nginx, three webhooks broke because the URL structure changed slightly and two of the external services did not retry.
Set up the full stack from the start. It takes an extra 30 minutes and saves a lot of debugging later.