I run 8 services in production on a single $12/month VPS:
- n8n (automation)
- Listmonk (newsletters)
- NocoDB (task management)
- PostgreSQL (database)
- Excalidraw (diagrams)
- OpenClaw (AI agent)
- Markdown viewer
- Assets CDN
Uptime: 99.8% (6 months).
SSL: Automatic wildcard cert (Let's Encrypt).
Deployment time: 5 minutes per new service.
Equivalent SaaS costs: $59/month → Saving $552/year.
It's not magic. It's Docker + Caddy (the reverse proxy you should be using instead of nginx).
I'll show you the complete setup (with configs ready to copy).
The Problem: Hosting Multiple Services Is Tedious
Traditional setup (nginx + manual SSL):
- Install service on host (dependency hell)
- Configure nginx location block (regex hell)
- Setup certbot for SSL
- Create cron for renewal
- Pray nothing breaks
Per service: 30-60 minutes.
SSL renewal: Randomly fails every 3 months (cert expired → downtime).
Rollback: Impossible (you installed directly on the host).
The Solution: Docker + Caddy
Docker: Containers = isolation + portability.
Caddy: Reverse proxy with automatic SSL (zero config).
Setup time: 5 minutes per service.
SSL: Automatic (Caddy requests + renews certs).
Rollback: docker run old_image.
Architecture (High-Level)
Internet
↓
Cloudflare DNS (*.yourdomain.com → VPS IP)
↓
Caddy (reverse proxy, port 443)
↓
├─ n8n.yourdomain.com → Docker container n8n:5678
├─ listmonk.yourdomain.com → Docker container listmonk:9000
├─ nocodb.yourdomain.com → Docker container nocodb:8080
└─ assets.yourdomain.com → Static files /var/www/assets/
PostgreSQL (Docker internal network)
↑
├─ Listmonk connects via Docker network
└─ NocoDB connects via Docker network
Key insight: Services do NOT expose ports to the internet. Only Caddy (443) is exposed.
Docker Compose (Main Services)
File: /home/user/docker/docker-compose.yml
version: '3.8'
services:
# PostgreSQL (shared database)
postgres:
image: postgres:15
container_name: postgres
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: listmonk
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- internal
# Listmonk (newsletter platform)
listmonk:
image: listmonk/listmonk:latest
container_name: listmonk
restart: always
ports:
- "9000:9000"
depends_on:
- postgres
environment:
LISTMONK_app__address: "0.0.0.0:9000"
LISTMONK_db__host: postgres
LISTMONK_db__port: 5432
LISTMONK_db__user: ${POSTGRES_USER}
LISTMONK_db__password: ${POSTGRES_PASSWORD}
LISTMONK_db__database: listmonk
networks:
- internal
# n8n (workflow automation)
n8n-dev:
image: n8nio/n8n:latest
container_name: n8n-dev
restart: always
ports:
- "5678:5678"
environment:
- N8N_HOST=n8n.yourdomain.com
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://n8n.yourdomain.com/
volumes:
- n8n_data:/home/node/.n8n
networks:
- internal
# NocoDB (Airtable alternative)
nocodb:
image: nocodb/nocodb:latest
container_name: nocodb
restart: always
ports:
- "8081:8080"
depends_on:
- postgres
environment:
NC_DB: "pg://postgres:5432?u=${POSTGRES_USER}&p=${POSTGRES_PASSWORD}&d=nocodb"
volumes:
- nocodb_data:/usr/app/data
networks:
- internal
# Excalidraw (diagram tool)
excalidraw:
image: excalidraw/excalidraw:latest
container_name: excalidraw
restart: always
ports:
- "8080:80"
networks:
- internal
volumes:
postgres_data:
n8n_data:
nocodb_data:
networks:
internal:
driver: bridge
# Start all services
cd /home/user/docker
docker-compose up -d
# View logs
docker-compose logs -f listmonk
# Stop
docker-compose down
Caddy (Reverse Proxy + Automatic SSL)
File: /etc/caddy/Caddyfile
# Global options
{
email your-email@example.com
# Cloudflare DNS challenge for wildcard certs
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# n8n (automation)
n8n.yourdomain.com {
reverse_proxy localhost:5678
header {
Strict-Transport-Security "max-age=31536000;"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
}
}
# Listmonk (newsletter)
listmonk.yourdomain.com {
reverse_proxy localhost:9000
}
# NocoDB (task manager)
nocodb.yourdomain.com {
reverse_proxy localhost:8081
}
# Excalidraw (diagrams)
draw.yourdomain.com {
reverse_proxy localhost:8080
}
# Assets CDN (static files)
assets.yourdomain.com {
root * /var/www/assets
file_server browse
# CORS for public assets
header Access-Control-Allow-Origin "*"
}
# Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# Reload config (zero downtime)
sudo caddy reload --config /etc/caddy/Caddyfile
Why Caddy > nginx
Configuration Comparison
nginx (30 lines to configure a single service with SSL):
server {
listen 80;
server_name n8n.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name n8n.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/n8n.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8n.yourdomain.com/privkey.pem;
# ... more SSL config ...
location / {
proxy_pass http://localhost:5678;
proxy_set_header Host $host;
# ... more headers ...
}
}
Caddy (2 lines):
n8n.yourdomain.com {
reverse_proxy localhost:5678
}
SSL Renewal
nginx: Install certbot, run certbot --nginx, create cron, hope it doesn't break.
Caddy: Nothing. Automatic SSL renewal built-in.
Setup Time
nginx: Config (15 min) + Certbot (10 min) + SSL debugging (10 min) = 35 min
Caddy: Config (2 min) + SSL (automatic) = 2 min
Adding a New Service (5 Minutes)
Example: Adding Plausible Analytics
1. Add to Docker Compose
plausible:
image: plausible/analytics:latest
container_name: plausible
restart: always
ports:
- "8082:8000"
environment:
BASE_URL: https://analytics.yourdomain.com
SECRET_KEY_BASE: ${PLAUSIBLE_SECRET}
networks:
- internal
2. Add to Caddyfile
analytics.yourdomain.com {
reverse_proxy localhost:8082
}
3. Deploy
# Start container
docker-compose up -d plausible
# Reload Caddy (zero downtime)
caddy reload --config /etc/caddy/Caddyfile
# Verify
curl https://analytics.yourdomain.com
Total time: 5 minutes.
SSL: Already works (wildcard cert covers analytics.yourdomain.com).
Wildcard SSL (DNS Challenge)
Why wildcard:
- 1 cert covers all
*.yourdomain.com - Adding a new subdomain = 0 SSL work
Cloudflare API Setup:
- Cloudflare dashboard → API Tokens
- Create Token → Edit Zone DNS
- Permissions:
Zone:DNS:Edit - Zone:
yourdomain.com
export CLOUDFLARE_API_TOKEN="your_token_here"
echo 'export CLOUDFLARE_API_TOKEN="your_token_here"' >> ~/.bashrc
Caddy auto-requests the cert: Requests wildcard from Let's Encrypt → creates DNS TXT via Cloudflare API → Let's Encrypt verifies → issues cert. Total time: 30-60 seconds. Manual steps: 0.
Backups
PostgreSQL (daily):
#!/bin/bash
# backup-postgres.sh
docker exec postgres pg_dumpall -U listmonk | gzip > /backups/postgres-$(date +%Y-%m-%d).sql.gz
# Keep last 7 days only
find /backups -name "postgres-*.sql.gz" -mtime +7 -delete
Cron:
0 2 * * * /home/user/backup-postgres.sh
Results (6 Months in Production)
Uptime: 99.8% (only 1 reboot for kernel update).
Services running: 8.
SSL cert renewals: 12 (all automatic, 0 manual).
Deployment time: 5 min avg per service.
New services added: 5.
Time saved: 40 min per deployment (vs nginx) × 5 deployments = 3.3 hours
Costs
| Item | Cost |
|---|---|
| VPS (Hostinger) — 4 CPU, 8 GB RAM | $12/month |
| Domain | $1/month |
| Cloudflare | $0 |
| Let's Encrypt SSL | $0 |
| Docker + Caddy | $0 |
| Total | $13/month |
vs SaaS equivalent:
- n8n Cloud: $20/month
- Managed Listmonk: $29/month
- NocoDB Cloud: $10/month
- Total SaaS: $59/month
Annual savings: $552
Setup From Scratch (Checklist)
- [ ] VPS (Ubuntu 22.04+) — Hostinger VPS works great for this
- [ ] Domain (with DNS access)
- [ ] Cloudflare account (for wildcard SSL)
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
Then: create docker-compose.yml, configure Caddyfile, docker-compose up -d, caddy reload.
Conclusion
Setup time: 1 day (one-time).
Annual savings: $552.
ROI: $552 saved / 8 hours setup = $69/hour.
When to self-host:
- You already pay for a VPS
- You have 3+ services to run
- You want full control
When NOT to:
- You don't know Docker (and don't want to learn)
- Total SaaS costs are <$50/month (not worth the effort)
If you're paying >$50/month on SaaS you could self-host, this setup pays for itself immediately.
Do you self-host? What's your stack? Share in the comments.
📝 Originally published in Spanish at cristiantala.com
Top comments (0)