Por años he hosteado mis sitios, comencé en 2007 con Go2linux, primero en un shared hosting, pero luego lo pasé a Linode, en 2008. Desde entonces he experimentado con muchas herramientas para hacer lo más sencillo y robusto posible. Pasé a Wordpress, y sitios estáticos. He usado Nginx tanto como server y como reverse proxy, hablando de reverse proxies, use mucho Varnish. Ahora estoy usando un stack más moderno, pero aún en mi VPS en Linode.

La idea central

Cada servicio vive en su propio directorio con su propio docker-compose.yml. Todos los servicios comparten una red Docker externa. Un container de Caddy se para adelante de todo, maneja SSL automáticamente y enruta el tráfico al container correcto según el hostname.

/home/user/
├── caddy/
│   ├── docker-compose.yml
│   └── Caddyfile
├── miapp/
│   ├── docker-compose.yml
│   └── .env
└── otraapp/
    ├── docker-compose.yml
    └── .env

La red compartida

Se crea una sola vez:

docker network create web

Cada docker-compose.yml que necesite ser accesible desde Caddy se une a esta red:

networks:
  web:
    external: true

Los containers en la misma red se pueden comunicar usando el nombre del container. Caddy hace proxy hacia miapp:3000 sin necesidad de exponer puertos al host.

Caddy como reverse proxy

Caddy gestiona SSL automáticamente via Let's Encrypt. Sin certbot, sin cron jobs, sin renovaciones manuales. Todo muy limpio y fácil de gestionar.

Un Caddyfile mínimo:

miapp.ejemplo.com {
    reverse_proxy miapp:3000
}

otraapp.ejemplo.com {
    reverse_proxy otraapp:8080
}

El docker-compose.yml de Caddy:

services:
  caddy:
    image: caddy:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - web

networks:
  web:
    external: true

volumes:
  caddy_data:
  caddy_config:

Cada servicio en su propio directorio

Un docker-compose.yml típico para una app:

services:
  miapp:
    image: miapp:latest
    container_name: miapp
    restart: unless-stopped
    env_file: .env
    networks:
      - web

networks:
  web:
    external: true

Sin ports expuestos al host. Caddy llega al container a través de la red compartida. La app no es accesible directamente desde internet.

Los secretos van en .env, que debe estar en .gitignore. Nunca commitear credenciales.

Datos persistentes

Usá volúmenes nombrados para bases de datos y cualquier dato que deba sobrevivir recreaciones del container:

services:
  db:
    image: postgres:16
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Los bind mounts también funcionan, pero los volúmenes nombrados son más fáciles de hacer backup y mover.

Actualizar un servicio

docker compose pull
docker compose up -d

Dos comandos. El container viejo para, el nuevo arranca, Caddy sigue enrutando tráfico. El downtime es de unos pocos segundos como máximo.

Lo que no necesitás

  • Kubernetes — diseñado para flotas de máquinas, agrega complejidad enorme para un solo VPS
  • Docker Swarm — útil para setups multi-nodo, excesivo para un servidor
  • Un registry privado — el tier gratuito de Docker Hub es suficiente para proyectos personales
  • Un load balancer separado — Caddy lo maneja

Cuándo este esquema se queda corto

Este setup funciona bien para un solo VPS. Si necesitás escalar horizontalmente a múltiples servidores, lo vas a superar. Pero para proyectos personales, apps pequeñas y herramientas self-hosted, un VPS bien dimensionado aguanta más de lo que esperás.

Y hoy en día con la ayuda de IA es mucho más sencillo de poner todo esto a funcionar, solo debes entender algo de aquitectura, dar las órdenes precisas et voilá!