Cómo Corro 8 Servicios en 1 VPS con SSL Automático (Y Por Qué Caddy > nginx)
Corro 8 servicios en producción en 1 VPS de $12/mes:
- n8n (automation)
- Listmonk (newsletters)
- NocoDB (task management)
- PostgreSQL (database)
- Excalidraw (diagrams)
- OpenClaw (AI agent)
- Markdown viewer
- Assets CDN
Uptime: 99.8% (6 meses).
SSL: Wildcard cert automático (Let’s Encrypt).
Deployment time: 5 minutos por servicio nuevo.
Costos SaaS equivalentes: $59/mes → Ahorro $552/año.
No es magia. Es Docker + Caddy (el reverse proxy que deberías estar usando en vez de nginx).
Te muestro el setup completo (con configs listas para copiar).
El Problema: Hosting Múltiples Servicios es Tedioso
Setup tradicional (nginx + manual SSL):
- Instalar servicio en host (dependency hell)
- Configurar nginx location block (regex hell)
- Setup certbot para SSL
- Crear cron para renewal
- Rezar que no rompa nada
Por servicio: 30-60 minutos.
SSL renewal: Falla random cada 3 meses (cert expired → downtime).
Rollback: Imposible (instalaste directo en host).
La Solución: Docker + Caddy
Docker: Containers = isolation + portability.
Caddy: Reverse proxy con SSL automático (zero config).
Setup time: 5 minutos por servicio.
SSL: Automático (Caddy requests + renews certs).
Rollback: docker run old_image.
Arquitectura (High-Level)
Internet
↓
Cloudflare DNS (*.yourdomain.com → VPS IP)
↓
Caddy (reverse proxy, puerto 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 conecta vía Docker network
└─ NocoDB conecta vía Docker network
Key insight: Servicios NO exponen puertos al internet. Solo Caddy (443) está expuesto.
Docker Compose (Servicios Principales)
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
Levantar:
cd /home/user/docker
docker-compose up -d
Ver logs:
docker-compose logs -f listmonk
Parar:
docker-compose down
Caddy (Reverse Proxy + SSL Automático)
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 "*"
}
## Wildcard catch-all
*.yourdomain.com {
respond "Service not configured" 404
}
Instalar 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
SSL: Caddy lo hace automático. First request triggers cert issuance.
Wildcard SSL (DNS Challenge)
Por qué wildcard:
- 1 cert cubre todos
*.yourdomain.com - Agregar subdomain nuevo = 0 work (cert ya existe)
Setup Cloudflare API:
- Cloudflare dashboard → API Tokens
- Create Token → Edit Zone DNS
- Permissions:
Zone:DNS:Edit - Zone:
yourdomain.com - Copy token
Set env var:
export CLOUDFLARE_API_TOKEN="your_token_here"
## Make persistent
echo 'export CLOUDFLARE_API_TOKEN="your_token_here"' >> ~/.bashrc
Caddy auto-requests cert:
- Detects no cert for
*.yourdomain.com - Requests wildcard from Let’s Encrypt
- Let’s Encrypt asks for DNS TXT record proof
- Caddy creates TXT via Cloudflare API
- Let’s Encrypt verifies → issues cert
- Caddy stores cert, serves HTTPS
Total time: 30-60 seconds (first request).
Manual steps: 0.
Por Qué Caddy > nginx
Configuración
nginx:
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:5678;
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;
}
}
Caddy:
n8n.yourdomain.com {
reverse_proxy localhost:5678
}
Diferencia: 30 líneas vs 2 líneas.
SSL Renewal
nginx:
- Install certbot
- Run
certbot --nginx - Create cron:
0 0 * * * certbot renew - Hope it doesn’t break
Caddy:
- Nada. SSL renewal automático (built-in).
Tiempo de Setup
nginx:
- Config: 15 min
- Certbot: 10 min
- Debugging SSL: 10 min
- Total: 35 min
Caddy:
- Config: 2 min
- SSL: automático
- Total: 2 min
Agregar Nuevo Servicio (5 Minutos)
Ejemplo: Agregar Plausible Analytics
1. 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. 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 minutos.
SSL: Ya funciona (wildcard cert cubre analytics.yourdomain.com).
Docker Networking (Por Qué No Hay Port Conflicts)
Sin Docker:
- Service A needs port 5000
- Service B also needs port 5000
- Conflict: Can’t run both
Con Docker:
- Service A:
ports: "5000:5000"(host 5000 → container 5000) - Service B:
ports: "5001:5000"(host 5001 → container 5000) - Both run simultaneously
Bonus: Internal communication vía service name:
## Listmonk se conecta a PostgreSQL
LISTMONK_db__host: postgres # Service name, not IP
Resource Limits (Prevenir Runaway Containers)
Problema: Un container consume toda la RAM → mata otros services.
Solución: Resource limits en docker-compose:
services:
n8n-dev:
image: n8nio/n8n
deploy:
resources:
limits:
memory: 2G
cpus: '1.5'
reservations:
memory: 512M
cpus: '0.5'
Resultado: n8n max 2 GB RAM, 1.5 CPUs. Otros services safe.
Monitoring
Health Checks
## Check all containers
docker ps
## Resource usage
docker stats
## Logs for errors
docker-compose logs --tail=100 | grep -i error
Uptime Monitoring
- Tool: UptimeRobot (free tier)
- Monitors: n8n, listmonk, draw, assets
- Alert: Telegram if >5 min downtime
Disk Usage
## Docker disk usage
docker system df
## Clean old images/containers
docker system prune -a
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
Docker volumes (semanal):
docker run --rm -v postgres_data:/data -v /backups:/backup alpine tar czf /backup/postgres-data-$(date +%Y-%m-%d).tar.gz /data
Rollback (Si Update Rompe Algo)
Problema: Actualizaste n8n, dejó de funcionar.
Rollback:
## Ver imágenes disponibles
docker images | grep n8n
## Correr versión anterior
docker stop n8n-dev
docker run -d --name n8n-dev-rollback \
--network docker_internal \
-p 5678:5678 \
-v n8n_data:/home/node/.n8n \
n8nio/n8n:0.228.0 # Previous version
## Update Caddy (si cambió puerto)
caddy reload
Downtime: <30 segundos.
Resultados (6 Meses Producción)
Uptime: 99.8% (solo 1 reboot para kernel update).
Services running: 8.
SSL cert renewals: 12 (todos automáticos, 0 manual).
Deployment time: 5 min avg por servicio.
New services added: 5 (Listmonk, NocoDB, Excalidraw, Markdown viewer, Assets CDN).
Tiempo ahorrado:
- nginx + certbot setup: 45 min/servicio
- Caddy setup: 5 min/servicio
- Ahorro: 40 min per deployment
- Total (5 deployments): 3.3 horas
Costos
VPS (Hostinger, 4 CPU, 8 GB RAM): $12/mes
Domain: $12/año ($1/mes)
Cloudflare: $0 (free tier)
Let’s Encrypt SSL: $0
Docker: $0 (open source)
Caddy: $0 (open source)
Total: $13/mes
vs SaaS equivalent:
- n8n Cloud: $20/mes
- Managed Listmonk: $29/mes
- NocoDB Cloud: $10/mes
- Total SaaS: $59/mes
Ahorro annual: $552/año
Lecciones Aprendidas
1. Caddy > nginx Para Small Teams
nginx: Requiere expert-level config knowledge.
Caddy: Funciona out-of-the-box para 95% casos de uso.
Cuándo SÍ usar nginx:
- Necesitas features específicos (ngx_lua, advanced rate limiting)
- Team tiene expertise nginx
- High-traffic site (>100K requests/day)
Cuándo usar Caddy:
- Small team (<5 people)
- No tienes DevOps dedicado
- Quieres SSL automático
- Prioridad: simplicity > advanced features
2. Wildcard Certs = Game Changer
Sin wildcard:
- n8n.domain.com → cert 1
- listmonk.domain.com → cert 2
- draw.domain.com → cert 3
Con wildcard:
- *.domain.com → 1 cert
- Covers ALL subdomains
- Add new subdomain = 0 cert work
3. Docker Networks Solve Port Conflicts
Antes de Docker: «Service X needs port 5000 but it’s taken.»
Con Docker: «Service X container port 5000, host port 5001. Done.»
4. Environment Variables > Hardcoded Secrets
Mal:
environment:
POSTGRES_PASSWORD: "my_password_123"
Bien:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Load from .env:
## .env file
POSTGRES_PASSWORD=super_secret_password
CLOUDFLARE_API_TOKEN=abc123
Por qué: Secrets no se commitean a git.
5. Resource Limits Prevent Cascading Failures
Un container runaway NO debe matar server completo.
Solución: Memory + CPU limits en todos los services.
Setup Desde Cero (Checklist)
Pre-requisitos
- [ ] VPS (Ubuntu 22.04+)
- [ ] Domain (con acceso a DNS)
- [ ] Cloudflare account (para wildcard SSL)
Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
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
Setup Cloudflare
- [ ] Create API token (Edit Zone DNS)
- [ ] Add to environment:
export CLOUDFLARE_API_TOKEN="..."
Create docker-compose.yml
- [ ] Copy template from repo
- [ ] Update environment variables
- [ ]
docker-compose up -d
Configure Caddy
- [ ] Create
/etc/caddy/Caddyfile - [ ] Add services (reverse_proxy entries)
- [ ]
caddy reload
Test
- [ ] Access https://n8n.yourdomain.com
- [ ] Verify SSL cert valid
- [ ] Check logs for errors
Conclusión: Self-Hosting FTW
Setup time: 1 día (one-time).
Ahorro annual: $552.
Control: 100% (vs vendor lock-in).
ROI: $552 saved / 8 horas setup = $69/hora.
Bonus:
- Learn Docker (transferable skill)
- Learn Caddy (simpler than nginx)
- Full data ownership
Cuándo self-hostear:
- Ya pagas VPS
-
3 services to run
- Quieres control total
Cuándo NO:
- No sabes Docker (y no quieres aprender)
- <$50/mes total SaaS costs (no worth effort)
Si pagas >$50/mes en SaaS que puedes self-hostear, esta setup paga su peso en oro.
Repo con configs completos:
- GitHub: openclaw-production-guide
- Case 7: Infrastructure (Docker + Caddy)
- docker-compose.yml + Caddyfile ready-to-use
¿Self-hosteas? ¿Qué stack usas? Comparte en comentarios.



