Cómo Corro 8 Servicios en 1 VPS con SSL Automático (Y Por Qué Caddy > nginx)

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):

  1. Instalar servicio en host (dependency hell)
  2. Configurar nginx location block (regex hell)
  3. Setup certbot para SSL
  4. Crear cron para renewal
  5. 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:

  1. Cloudflare dashboard → API Tokens
  2. Create Token → Edit Zone DNS
  3. Permissions: Zone:DNS:Edit
  4. Zone: yourdomain.com
  5. 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:


¿Self-hosteas? ¿Qué stack usas? Comparte en comentarios.

Suscríbete a mi Newsletter

Estrategias de automatización, AI y startups que funcionan. Análisis semanal directo a tu inbox. (Sin spam, prometo intentarlo.)