Skip to content

Services routing — Caddy + Cloudflared + Sablier

Last updated: 2026-07-12

Documento canónico de cómo se publica un servicio en este homelab y cómo decides si se duerme o no. Single source of truth: las labels tunnel.* en el compose.yaml del servicio.

Arquitectura

Internet → Cloudflare → cloudflared (LXC 123)
                          ▼ (tunnel ingress → VIP .250:443)
                 VIP keepalived .250
                          │  (VRRP: MASTER LXC 270 / BACKUP LXC 271)
                 Caddy (LXC 270/271)  ← único ingress
                          ├─ [Sablier middleware] ← solo en hostnames "lazy"
                          │       │
                          │       ▼ (si container parado)
                          │   waiting page + arrancar via docker socket (VM 208)
                          └─ reverse_proxy → 192.168.0.208:<host_port>
  • cloudflared (LXC 123): tunnel managed via dashboard (token-based). Todos los ingress van al VIP https://192.168.0.250:443 (keepalived, servido por Caddy LXC 270/271) con noTLSVerify=true y originServerName=<hostname>. Caddy es el único origen.
  • Caddy (LXC 270 caddy-primary .247 / LXC 271 caddy-secondary .248, tras VIP keepalived .250): build custom con plugins caddy-dns/cloudflare + sablierapp/sablier/plugins/caddy. Cert wildcard *.monxas.casa via DNS-01 challenge. Admin :2019 con metrics. El container Caddy en VM 208 fue eliminado (post-F4.f).
  • Sablier (plugin en Caddy): para containers de la VM 208 cuando llevan rato sin tráfico, los arranca on-demand cuando alguien hace request al hostname. Necesita el Docker socket de VM 208.
  • DNS: registro wildcard *.monxas.casa apunta a Cloudflare → cualquier subdominio nuevo funciona sin tocar DNS.
  • homelab-ctl.py (~/scripts/homelab-ctl.py): reconcilia las labels tunnel.* (tunnel.hostname, tunnel.port, tunnel.target_port, tunnel.lazy*) de los containers running + las rutas estáticas de ~/scripts/tunnel-static.yaml con la config de cloudflared y managed.caddy. Reemplaza a tunnel-sync.py + sablier-watchdog.py (subcomandos status/sync/watch/update/smoke/remove).

Estado actual

Sablier — 13 hostnames lazy

Todos declarativos via labels tunnel.lazy* en el compose de cada servicio.

Hostname Containers Compose
pdf.monxas.casa stirling-pdf tools/compose.yaml
jellyseerr.monxas.casa jellyseerr media/compose.yaml
ocr.monxas.casa paddleocr, paddleocr-ui paddleocr-ui/docker-compose.yml
md, md-demo markdown_demo_prod, markdown_backend_prod markdown-tool/docker-compose.yml
md-api (idem) (idem)
airdrop.monxas.casa pairdrop tools/compose.yaml
nopor.monxas.casa stash tools/compose.yaml
japon.monxas.casa trip-planner, trip-planner-deploy trip-planner/docker-compose.yml
tunarr.monxas.casa tunarr media/compose.yaml
pinchflat.monxas.casa pinchflat tools/compose.yaml
tube.monxas.casa metube tools/compose.yaml
code.monxas.casa code-server tools/compose.yaml

always-on (NO lazy): paperless, bookmarks (karakeep), dockge — reverse_proxy directo, siempre disponibles. Eliminados del routing: ydl, gastos-app, comparador, orcaslicer, reddit-chat, open-webui.

Nota (2026-04-28): audiobookshelf (podcasts.monxas.casa) salió de Sablier por petición — siempre disponible, reverse_proxy directo. ABS con authMethods: ["local"] (sin OIDC pocketid).

Cloudflared tunnel

  • Todos los ingress apuntan al VIP: service: https://192.168.0.250:443 + noTLSVerify + originServerName. Caddy (LXC 270/271) rutea por hostname.
  • Los targets cross-host (HA .171, NAS .237, n8n .200, synology .160, RAG/Kiwix .188, etc.) se declaran en ~/scripts/tunnel-static.yaml y Caddy los enruta — ya no son ingress directos de cloudflared.

Caddy config (managed.caddy en LXC 270/271)

  • Hostnames bajo el sitio único *.monxas.casa { ... }.
  • Sección # BEGIN homelab-ctl managed ... # END regenerada por homelab-ctl.py desde las labels tunnel.* + tunnel-static.yaml.
  • 13 bloques lazy con Sablier + algunos pocos manuales legacy.

Métricas (2026-04-25)

  • RAM antes de Sablier: 7.9 GiB used
  • RAM después: 4.7 GiB used (−3.2 GiB)
  • Containers running: 88 → 61 (cuando todos los lazy están durmiendo)

Cómo publicar un servicio

Workflow único: poner labels en el compose, ejecutar homelab-ctl sync.

services:
  miapp:
    image: example/app
    ports:
      - 8080:80
    labels:
      - tunnel.hostname=miapp.monxas.casa
      - tunnel.lazy=true                       # opcional, lazy con Sablier
      - tunnel.lazy.names=miapp,miapp-db       # opcional, multi-container
      - tunnel.lazy.display_name=Mi App        # opcional
      # tunnel.port=8080                       # opcional (default = primer puerto publicado)
      # tunnel.target_port=80                  # opcional: puerto INTERNO del container (si difiere del publicado)
      # tunnel.lazy.theme=shuffle              # opcional (ghost/hacker-terminal/matrix/shuffle)
      # tunnel.lazy.session_duration=30m       # opcional
cd <stack>
docker compose up -d
python3 ~/scripts/homelab-ctl.py status     # ver cambios / drift
python3 ~/scripts/homelab-ctl.py sync       # aplicar (managed.caddy + tunnel + reload)

sync es idempotente. Es seguro correrlo cuantas veces quieras.

Cuándo SÍ marcar como lazy (tunnel.lazy=true)

  • UI-driven con uso esporádico (PDF, OCR, dashboards puntuales).
  • Container con RAM alta (>100 MiB) y arranque rápido (<30s).
  • No es dependencia de otro always-on.

Cuándo NO

  • Tráfico continuo (scrapers, notification listeners, eink pingers, smart TVs).
  • DB compartida sola (postgres/redis/meilisearch) — solo dormir junto al stack que la usa via tunnel.lazy.names.
  • Infra: caddy, cloudflared, sablier, prometheus, loki, grafana, healthchecks, monitoring agents.
  • Streaming (jellyfin) o uploads móvil aleatorios (immich).

Multi-hostname para el mismo container

Comma-sep: tunnel.hostname=admin.monxas.casa,login.monxas.casa,wp-admin.monxas.casa (ej. krawl honeypot).

Comandos del script

python3 ~/scripts/homelab-ctl.py status        # rutas deseadas + tunnel drift + estado
python3 ~/scripts/homelab-ctl.py sync          # aplicar managed.caddy + tunnel + reload (pregunta)
python3 ~/scripts/homelab-ctl.py sync --yes    # aplicar sin pregunta
python3 ~/scripts/homelab-ctl.py watch         # recrea containers ausentes que deberían existir
python3 ~/scripts/homelab-ctl.py update        # pull imágenes de servicios ruteados + recrea stale
python3 ~/scripts/homelab-ctl.py smoke         # HEAD a cada hostname ruteado; reporta 5xx/timeouts
python3 ~/scripts/homelab-ctl.py remove HOST   # quita HOST del ingress del tunnel

Caveats / gotchas conocidos

  1. caddy reload cachea el plugin Sablier: cuando se añade un bloque sablier nuevo (no presente al iniciar), hace falta docker compose down && up -d del stack reverse-proxy. El script ya hace este restart automáticamente cuando detecta sablier blocks nuevos. Editar bloques existentes sí funciona con reload.

  2. Movistar nullroutea Cloudflare R2 (172.64.0.0/13): docker pull de imágenes nuevas que viven en R2 falla con TCP timeout al CDN. Workaround para Caddy: bajar binario precompilado vía caddyserver.com/api/download y empaquetar en FROM caddy:2-alpine. Workaround general para imágenes que pullea: comprobar si están cacheadas localmente; si no, esperar a desbloqueo de R2 o usar imagen alternativa.

  3. El tunnel route DEBE apuntar a Caddy: si en cloudflared algún ingress apunta directo al container (e.g., http://192.168.0.208:8088), cuando el container está parado da 502 antes de que Sablier intercepte. Por eso unificamos al VIP https://192.168.0.250:443 (todos pasan por Caddy).

  4. Cloudflare Access: hostnames protegidos con Access (e.g., pdf.monxas.casa) interceptan ANTES de cloudflared. Si no tienes sesión Access, ves login en lugar de la waiting page de Sablier. La waiting page aparece después del auth.

  5. Acceso por IP:puerto salta Sablier: si bookmarkás 192.168.0.208:8000 para paperless, el container parado da 502. Para que Sablier los resucite, accede siempre por hostname.

  6. docker compose up -d recrea containers cuando detecta cambios en compose (incluido un label nuevo). Eso interrumpe momentáneamente sesiones activas (jellyfin streaming, etc.). Si añades labels en horario de uso intensivo, plantearlo.

  7. Containers parados no se ven por homelab-ctl: el script lee labels de containers running (docker ps). Si el container tiene labels en compose pero está parado, no se reconcilia. Se resuelve la próxima vez que el container arranque (e.g., al subir el stack).

  8. Servicios lazy NO deben estar en blackbox probes: blackbox-exporter en su job de Prometheus. Si pones un servicio lazy y blackbox lo sondea cada minuto, todas las requests despertarán al container Y/O dispararán alerts "Probe endpoint caido" cuando duerme. Quita el target del prometheus.yml + prometheus.

  9. Container ausente vs container parado (incidente 2026-04-28): Sablier sólo hace docker start <name>. Si el container fue borrado de Docker (no sólo parado), Sablier devuelve (0/0) y la wait-page cuelga indefinidamente. Diagnóstico rápido: docker exec caddy wget -qO- "http://sablier:10000/api/strategies/dynamic?names=<n>&session_duration=30m" | grep -E 'is (ready|starting|not-ready)'. Si dice (0/0), el container no existe — recrear con docker compose -f <stack>/compose.yaml create <svc> (no arranca el container, sólo lo crea para que Sablier pueda despertarlo).

  10. Watchdog automático (homelab-ctl.py watch, cron */5 * * * *; fusiona el antiguo sablier-watchdog.py): parsea los names de cada bloque sablier{} de managed.caddy, comprueba que cada container exista en Docker (VM 208), y si falta lo recrea con docker compose create. Idempotente. homelab-ctl.py update además hace pull + recrea imágenes stale de los servicios ruteados.

  11. NUNCA docker compose down en stacks con servicios lazy (incidente 2026-04-28, causa raíz): down borra containers; Sablier necesita que existan (aunque parados) para despertarlos. Si necesitas parar todo, usa docker compose stop (no borra). Para parar uno: docker stop <name>. El watchdog (homelab-ctl.py watch) recrea automáticamente containers ausentes cada 5 min, pero el spike de wait-pages colgadas hasta entonces es feo.

Rollback

Snapshot Proxmox de la VM 208 antes de la cirugía Sablier inicial: pre_sablier_20260425_1521.

ssh [email protected] "qm rollback 208 pre_sablier_20260425_1521"

Backups del Caddyfile generados por el script: Caddyfile.bak-<timestamp> en reverse-proxy/. Backups de la config de tunnel API: /tmp/cf-tunnel-pre-unify-*.json en VM 208.

Reference

  • Doc del script: ../operations/scripts/tunnel-sync.md
  • Sablier docs: https://sablierapp.dev/
  • Caddy plugin: github.com/sablierapp/sablier/plugins/caddy
  • Build Caddy custom (con plugins, vía API oficial): https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fcaddy-dns%2Fcloudflare&p=github.com%2Fsablierapp%2Fsablier%2Fplugins%2Fcaddy