DEV Community

Cristian Tala S.
Cristian Tala S.

Posted on • Originally published at cristiantala.com

Cómo Corro 8 Servicios en 1 VPS con SSL Automático

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Levantar:

cd /home/user/docker
docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

Ver logs:

docker-compose logs -f listmonk
Enter fullscreen mode Exit fullscreen mode

Parar:

docker-compose down
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Reload config (zero downtime):

sudo caddy reload --config /etc/caddy/Caddyfile
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Caddy:

n8n.yourdomain.com {
    reverse_proxy localhost:5678
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Caddyfile

analytics.yourdomain.com {
    reverse_proxy localhost:8082
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Cron:

0 2 * * * /home/user/backup-postgres.sh
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Bien:

environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Enter fullscreen mode Exit fullscreen mode

Load from .env:

# .env file
POSTGRES_PASSWORD=super_secret_password
CLOUDFLARE_API_TOKEN=abc123
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


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.

Top comments (0)