Why bother self-hosting at all
Picking a VPS
The docker-compose.yml I actually use
The .env file
Nginx + Let's Encrypt
Security hardening
Backups (the part everyone skips)
Update procedure
Pitfalls that bit me (so they don't bite you)
When to scale (queue mode)
Verdict — Self-hosting n8n in 2026 costs $4–12/month for the VPS and gives you unlimited workflow executions plus full data control. The setup is Docker Compose with PostgreSQL behind an Nginx reverse proxy with Let's Encrypt SSL — about 30 minutes if you've used a Linux server before. This is the production config I actually run, plus the gotchas that bite people in week two. I've set up self-hosted n8n for myself and a handful of clients over the past year. Every time, I went looking for "the one good guide" and ended up stitching together six tutorials, two GitHub issues, and one Reddit thread. Here's the consolidated version, biased toward the choices that survive contact with real production traffic. n8n Cloud starts at €24/month for 2,500 executions. A €4 Hetzner VPS gives you unlimited executions and complete data control. The math becomes obvious fast — but cost isn't the only reason. Self-hosting wins when: The trade-off is real: you own uptime, backups, security patches, SSL renewal. If that sounds painful, look at managed n8n hosting instead — it's $7–25/month and someone else handles the boring parts. For broader options, here's a comparison of Zapier alternatives including n8n. Minimum: 1 vCPU, 1 GB RAM, 25 GB SSD — fine for personal use, enable swap to avoid OOM kills.
Production: 2 vCPU, 4 GB RAM, 40+ GB SSD. Sweet spot for 90% of self-hosters. Hetzner CAX11 is what I run for everything that doesn't need US latency. The €3.29/month for 2 vCPU + 4 GB ARM is genuinely unbeatable — only friction is the identity verification on signup that takes a few hours. SQLite is fine for testing. For production, Postgres is the right call — survives container restarts cleanly, easy to back up, scales when you grow. The detail most guides skip: the 127.0.0.1:5678 binding instead of 0.0.0.0. This means n8n is reachable only via the local Nginx reverse proxy, never directly from the internet. Non-negotiable for production. Two variables you need to internalize: Install Nginx and Certbot, point your A record at the VPS, then /etc/nginx/sites-available/n8n.conf: proxy_read_timeout 86400 matters — without it, long-running n8n executions get killed by Nginx. The Upgrade and Connection headers are required for the editor's WebSocket connection. A self-hosted n8n with weak security is a credential theft waiting to happen. The platform stores API keys, database passwords, OAuth tokens — exactly what attackers want. Lock it down before connecting your first integration: n8n's database holds workflows, credentials, and execution history. Lose it and you've lost months of work. The backup must include the PostgreSQL dump and the n8n data volume (which holds the encryption key file). Backing up only one is useless. Test your restore process at least once. A backup you've never restored from is not a backup — it's a hope. n8n ships frequently — minor versions for fixes, major versions every few months with potential breaking changes. Read release notes before any major upgrade. Database migrations are sometimes irreversible. Safest pattern: pin a specific version, watch the changelog, upgrade deliberately rather than auto-pulling latest. Once stable, log everything that crosses webhook boundaries — debugging without it is brutal. Here's the logging pattern I use for webhooks. The single-instance setup above handles 5–8 concurrent workflows comfortably on 2 vCPU / 4 GB. Most SMBs never outgrow it — API latency, not your VPS, is the bottleneck. If you do hit the ceiling, switch to queue mode with Redis. Add a Redis service to compose, set EXECUTIONS_MODE=queue and QUEUE_HEALTH_CHECK_ACTIVE=true, run separate worker containers. This is essentially the architecture n8n Cloud uses internally — you're just doing it on your own infra. Self-hosting n8n in 2026 is genuinely a great deal if you're comfortable with a Linux server. Total cost: $4–15/month including off-site backups, vs €24+/month for n8n Cloud's entry tier with execution caps. The maintenance burden after initial setup is roughly 1 hour per month — pulling an image, checking backups, occasional log review. Switch to managed hosting only when you find yourself spending 4+ hours/month on n8n ops. At that point, $7–25/month for someone else to handle Docker pulls and backup verification is just better economics — and frees you to actually build automations. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
services: postgres: image: postgres:16-alpine container_name: n8n-db restart: unless-stopped environment: POSTGRES_DB: n8n POSTGRES_USER: n8n POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data networks: - n8n-network healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n"] interval: 10s timeout: 5s retries: 5 n8n: image: docker.n8n.io/n8nio/n8n:latest container_name: n8n 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 - WEBHOOK_URL=https://${DOMAIN}/ - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_RUNNERS_ENABLED=true - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - GENERIC_TIMEZONE=${TZ} - TZ=${TZ} - NODE_ENV=production volumes: - n8n_data:/home/node/.n8n depends_on: postgres: condition: service_healthy networks: - n8n-network volumes: postgres_data: n8n_data: networks: n8n-network: driver: bridge
services: postgres: image: postgres:16-alpine container_name: n8n-db restart: unless-stopped environment: POSTGRES_DB: n8n POSTGRES_USER: n8n POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data networks: - n8n-network healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n"] interval: 10s timeout: 5s retries: 5 n8n: image: docker.n8n.io/n8nio/n8n:latest container_name: n8n 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 - WEBHOOK_URL=https://${DOMAIN}/ - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_RUNNERS_ENABLED=true - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - GENERIC_TIMEZONE=${TZ} - TZ=${TZ} - NODE_ENV=production volumes: - n8n_data:/home/node/.n8n depends_on: postgres: condition: service_healthy networks: - n8n-network volumes: postgres_data: n8n_data: networks: n8n-network: driver: bridge
services: postgres: image: postgres:16-alpine container_name: n8n-db restart: unless-stopped environment: POSTGRES_DB: n8n POSTGRES_USER: n8n POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data networks: - n8n-network healthcheck: test: ["CMD-SHELL", "pg_isready -U n8n"] interval: 10s timeout: 5s retries: 5 n8n: image: docker.n8n.io/n8nio/n8n:latest container_name: n8n 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 - WEBHOOK_URL=https://${DOMAIN}/ - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_RUNNERS_ENABLED=true - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - GENERIC_TIMEZONE=${TZ} - TZ=${TZ} - NODE_ENV=production volumes: - n8n_data:/home/node/.n8n depends_on: postgres: condition: service_healthy networks: - n8n-network volumes: postgres_data: n8n_data: networks: n8n-network: driver: bridge
POSTGRES_PASSWORD=$(openssl rand -base64 32)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
DOMAIN=automation.yourdomain.com
TZ=Europe/Kyiv
POSTGRES_PASSWORD=$(openssl rand -base64 32)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
DOMAIN=automation.yourdomain.com
TZ=Europe/Kyiv
POSTGRES_PASSWORD=$(openssl rand -base64 32)
N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)
DOMAIN=automation.yourdomain.com
TZ=Europe/Kyiv
server { listen 80; server_name automation.yourdomain.com; return 301 https://$host$request_uri;
} server { listen 443 ssl http2; server_name automation.yourdomain.com; ssl_certificate /etc/letsencrypt/live/automation.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/automation.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 50M; 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-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; }
}
server { listen 80; server_name automation.yourdomain.com; return 301 https://$host$request_uri;
} server { listen 443 ssl http2; server_name automation.yourdomain.com; ssl_certificate /etc/letsencrypt/live/automation.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/automation.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 50M; 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-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; }
}
server { listen 80; server_name automation.yourdomain.com; return 301 https://$host$request_uri;
} server { listen 443 ssl http2; server_name automation.yourdomain.com; ssl_certificate /etc/letsencrypt/live/automation.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/automation.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 50M; 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-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; }
}
sudo certbot --nginx -d automation.yourdomain.com
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer
sudo certbot --nginx -d automation.yourdomain.com
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer
sudo certbot --nginx -d automation.yourdomain.com
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer
#!/bin/bash
# /usr/local/bin/n8n-backup.sh
BACKUP_DIR=/var/backups/n8n
DATE=$(date +%Y-%m-%d)
mkdir -p $BACKUP_DIR # PostgreSQL dump
docker exec n8n-db pg_dump -U n8n n8n | gzip > $BACKUP_DIR/db-$DATE.sql.gz # n8n data volume (encryption key, custom nodes)
docker run --rm -v n8n_data:/data -v $BACKUP_DIR:/backup \ alpine tar -czf /backup/n8n-data-$DATE.tar.gz -C /data . # Keep last 14 days locally
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete # Off-site sync
rclone copy $BACKUP_DIR remote:n8n-backups/
#!/bin/bash
# /usr/local/bin/n8n-backup.sh
BACKUP_DIR=/var/backups/n8n
DATE=$(date +%Y-%m-%d)
mkdir -p $BACKUP_DIR # PostgreSQL dump
docker exec n8n-db pg_dump -U n8n n8n | gzip > $BACKUP_DIR/db-$DATE.sql.gz # n8n data volume (encryption key, custom nodes)
docker run --rm -v n8n_data:/data -v $BACKUP_DIR:/backup \ alpine tar -czf /backup/n8n-data-$DATE.tar.gz -C /data . # Keep last 14 days locally
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete # Off-site sync
rclone copy $BACKUP_DIR remote:n8n-backups/
#!/bin/bash
# /usr/local/bin/n8n-backup.sh
BACKUP_DIR=/var/backups/n8n
DATE=$(date +%Y-%m-%d)
mkdir -p $BACKUP_DIR # PostgreSQL dump
docker exec n8n-db pg_dump -U n8n n8n | gzip > $BACKUP_DIR/db-$DATE.sql.gz # n8n data volume (encryption key, custom nodes)
docker run --rm -v n8n_data:/data -v $BACKUP_DIR:/backup \ alpine tar -czf /backup/n8n-data-$DATE.tar.gz -C /data . # Keep last 14 days locally
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete # Off-site sync
rclone copy $BACKUP_DIR remote:n8n-backups/
# crontab -e
0 3 * * * /usr/local/bin/n8n-backup.sh >> /var/log/n8n-backup.log 2>&1
# crontab -e
0 3 * * * /usr/local/bin/n8n-backup.sh >> /var/log/n8n-backup.log 2>&1
# crontab -e
0 3 * * * /usr/local/bin/n8n-backup.sh >> /var/log/n8n-backup.log 2>&1
# Always back up first
/usr/local/bin/n8n-backup.sh # Pull and restart
cd /opt/n8n
docker compose pull
docker compose up -d # Verify
docker compose logs -f n8n
# Always back up first
/usr/local/bin/n8n-backup.sh # Pull and restart
cd /opt/n8n
docker compose pull
docker compose up -d # Verify
docker compose logs -f n8n
# Always back up first
/usr/local/bin/n8n-backup.sh # Pull and restart
cd /opt/n8n
docker compose pull
docker compose up -d # Verify
docker compose logs -f n8n - You exceed 50,000 executions/month. Above this, every hosted automation platform becomes painful. n8n self-hosted has zero per-execution cost.
- Data residency matters. Healthcare, fintech, GDPR-strict EU scenarios — controlling the infra is the cleanest path to compliance.
- You need custom code or npm packages. Self-hosted lets you write JS or Python and install packages hosted platforms restrict.
- You want platform independence. No vendor lockout, no surprise pricing changes. - N8N_ENCRYPTION_KEY — encrypts every stored credential. Lose it and every saved API key, OAuth token, and password in your n8n is unrecoverable. Back it up to a password manager the moment you generate it. n8n auto-creates one if missing, but set it explicitly so you control it.
- WEBHOOK_URL — must match your public HTTPS URL exactly. Trailing slash matters. If webhooks don't fire after setup, this is almost always why. Webhooks are core to most n8n workflows — if you're new to them, see this practical webhook primer. - UFW firewall — allow only SSH (preferably non-default port), 80, 443. Block everything else.
- SSH hardening — disable password auth, keys only, PermitRootLogin no. Add fail2ban.
- Enable n8n's 2FA for the owner account immediately after first login.
- Pin the Docker image version in production: docker.n8n.io/n8nio/n8n:1.x.x rather than :latest.
- Restrict task runners — only set NODE_FUNCTION_ALLOW_EXTERNAL=* if absolutely necessary, never with untrusted code.
- Auto OS updates — unattended-upgrades on Debian/Ubuntu. - Webhooks return 404 / don't fire → wrong WEBHOOK_URL. Must match the public HTTPS URL exactly, trailing slash included. Restart container after fixing.
- OOM crashes on 1 GB VPS → add 2 GB swap. Realistically, just upgrade to 4 GB RAM.
- "Editor unreachable" or WebSocket errors → missing Upgrade/Connection headers in Nginx. Re-check the proxy block.
- Credential decryption failure after restore → encryption key missing from the restored data volume. Restore the full n8n_data volume, or set N8N_ENCRYPTION_KEY explicitly to the original.
- Slow execution after a few weeks → execution history bloating the DB. Set EXECUTIONS_DATA_PRUNE=true and EXECUTIONS_DATA_MAX_AGE=336 (hours, = 14 days).
- Backups silently failing → verify cron with grep CRON /var/log/syslog. Set up a dead-man's-switch monitor that pings you when the backup doesn't run.