Module 12 · ship · advanced
Lesson 12 · Self-hosting and Client Delivery
Goal
Move from "works on my laptop" to a production n8n you'd trust with a client's business: real domain with HTTPS, a proper database, backups you've tested, monitoring, and a handoff that doesn't leave the client dependent on your personal accounts.
Prerequisites
- Modules 01–11 (especially 04: error workflows)
- A domain you control and a small Linux VPS (any provider: Hetzner, DigitalOcean, Vultr, Lightsail…; 2 GB RAM is a comfortable start)
Concepts
- Postgres over SQLite: concurrent executions, better durability, standard backups. n8n 2.43.6 warns that Postgres 16 is outside the supported range; use 17.
- Reverse proxy with automatic HTTPS: Caddy gets Let's Encrypt certificates on its own. n8n itself is never exposed directly.
WEBHOOK_URL/N8N_HOST: every public URL n8n generates (webhooks, forms, chat, OAuth redirects) is built from these. Wrong values give "redirect_uri_mismatch" and broken form links.N8N_ENCRYPTION_KEY: encrypts all stored credentials. Lose it and every credential must be re-created. Store it in a password manager before the first start.- Backups you've restored: an untested backup is a hope, not a backup.
- Pinned versions:
N8N_VERSION=2.43.6. Upgrade on purpose, after reading the release notes, with a backup first.
Build it
Step 1: Server and DNS
- Create the VPS (Ubuntu LTS), add your SSH key, then install Docker (Docker's official convenience script or apt repository).
- Firewall: allow 22, 80, 443 only.
- DNS: create an A record
n8n.yourdomain.com→ the server's IP. Wait untildig n8n.yourdomain.comreturns it.
Step 2: Deploy
# on the server
sudo mkdir -p /opt/n8n && sudo chown $USER /opt/n8n && cd /opt/n8n
# copy docker-compose.prod.yml, Caddyfile, .env.example, backup.sh from deploy/ (scp or git)
cp .env.example .env
openssl rand -hex 32 # → N8N_ENCRYPTION_KEY (save it in your password manager!)
openssl rand -base64 24 # → POSTGRES_PASSWORD
nano .env # set DOMAIN, the key, the password
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml logs -f n8n # wait for "Editor is now accessible"
Open https://n8n.yourdomain.com, create the owner account, and enable MFA (Settings → Personal).
Step 3: Backups
chmod +x backup.sh && ./backup.sh # first run
crontab -e # add: 30 2 * * * cd /opt/n8n && ./backup.sh >> backups/backup.log 2>&1
Each archive contains every workflow as JSON, all credentials (still encrypted with your key), and a full Postgres dump. Copy backups/ off the server (rclone/rsync to S3, B2 or another machine).
Test a restore once (Exercise 2).
Step 4: Monitoring
Import workflow/learner.json (12 - Uptime Monitor):
- Edit Sites to Check: your instance's
/healthzplus the client's critical URLs. - Wire Send Alert to Telegram/Slack/email (Exercise 1). Publish.
- Also point a free external uptime service at
https://n8n.yourdomain.com/webhook/ops/health. It returns 503 when anything is down, and it catches the case where n8n itself is down and can't alert you.
Then set the Module 04 Error Logger as the error workflow of every production workflow.
Step 5: Upgrades
./backup.sh
# edit .env: N8N_VERSION=<new version>
docker compose -f docker-compose.prod.yml pull n8n && docker compose -f docker-compose.prod.yml up -d
Check the release notes for breaking changes first. If something breaks: set the old version back and up -d again. Database migrations can make a rollback impossible, which is why you back up first.
Step 6: Hand off to a client
Work through deploy/handoff-checklist.md. The two rules that save the most pain:
- Client-owned everything: server, domain, n8n owner account, API keys, billing.
- Alerts reach the client, not just you.
Test it
./test/test.sh
Checks the monitor (200/503 paths) and validates the compose file, Caddyfile and backup script.
Common problems
- Caddy can't get a certificate: DNS not pointing at the server yet, or ports 80/443 blocked.
- Webhook/form URLs show
localhost:5678:WEBHOOK_URLnot set or wrong. Restart after fixing. - "Credentials could not be decrypted" after a move: the new server has a different
N8N_ENCRYPTION_KEY. - Disk filling up: execution data.
EXECUTIONS_DATA_PRUNE+EXECUTIONS_DATA_MAX_AGE(14 days here) keep it in check. - Backup exits early on a fresh instance: older versions of this script didn't handle "No workflows found"; this one does.