Skip to content

Crear un subdominio (*.monxas.casa)

Exponer un servicio nuevo bajo *.monxas.casa con uno de tres modos de protección: sin auth, CF Access (PocketID), o CF Access con bypass de API para apps móviles/CLIs.

Complementa a Adding a service (flujo Caddy/homelab-ctl.py) y Cloudflare Access + PocketID.

Atajo automatizado: scripts/add-service.sh (2026-08-07)

Para el caso común — backend en otro LXC/host que no sea VM208 (no Docker, cross-host), gateado con CF Access + PocketID (Modo B de abajo) — los tres pasos de este documento (ingress del Tunnel, Access app+policy, bloque Caddy en ambos nodos HA) están automatizados en un único script:

./scripts/add-service.sh <hostname-sin-dominio> <backend-ip:puerto> ["Nombre CF Access"]
# ej: ./scripts/add-service.sh miapp 192.168.0.208:8080 "Mi App"

# revertir (quita ingress + Access app + bloque Caddy en ambos nodos):
./scripts/add-service.sh --remove <hostname-sin-dominio>

Lee credenciales de secrets/cloudflare.sops.yaml en tiempo de ejecución (nunca hardcodeadas), resuelve el IdP PocketID vía API (no hardcodeado), verifica cada paso antes de seguir (re-GET tras cada PUT/POST, diff de md5 del Caddyfile entre los 2 nodos), y es idempotente — reejecutarlo no duplica nada. Probado end-to-end 2026-08-07 (alta + baja de un hostname de prueba, limpio en los 3 planos). No cubre Modo A (sin Access) ni Modo C (bypass de API) — para esos, o para servicios Docker en VM208, sigue la receta manual de abajo / Adding a service.

Reescrito 2026-08-01 — no hay dos caminos, solo uno

Versiones anteriores de esta guía describían "dos caminos de routing": uno directo VM208→tunnel sin pasar por Caddy, y otro vía Caddy HA. Eso ya no es así. Verificado leyendo homelab-ctl.py (el script real, md5-idéntico entre el repo y lo desplegado en VM 208): build_tunnel_entry() apunta siempre el ingress del CF Tunnel al VIP de Caddy HA (https://192.168.0.250:443), nunca a VM208 directo — el propio comentario del script dice que nada escucha ya en VM208:443 desde el cutover a Caddy HA, y que apuntar el tunnel ahí tumbaría todos los hostnames a la vez. Todo pasa por Caddy. La única distinción real es de dónde saca la ruta homelab-ctl.py (ver Paso 1), no por dónde entra el tráfico.

Paso 1 — Declarar la ruta

Dos formas de que homelab-ctl.py sepa de tu servicio, según dónde viva:

A) Container Docker en VM208 — label en el compose:

~/stacks/<stack>/compose.yaml en VM208:

services:
  miapp:
    image: example/miapp:latest
    ports:
      - "4533:4533"          # host:container
    labels:
      - tunnel.hostname=miapp.monxas.casa
      # - tunnel.port=4533   # opcional; default = primer puerto publicado

Recrear el container para que coja la label:

cd ~/stacks/<stack> && docker compose up -d miapp

B) Servicio fuera de VM208 (otro LXC/VM, sin Docker, cross-host) — entrada en ~/scripts/tunnel-static.yaml en VM208 (ejemplos reales ya en el fichero: n8n.monxas.casa → n8n.lan:5678, home.monxas.casa → ha.lan:8123):

routes:
- hostname: miapp.monxas.casa
  target: miapp.lan:PORT
  note: por qué vive fuera de VM208

No uses labels tunnel.upstream — ese label no existe en el script actual.

Paso 2 — Sincronizar (Caddy + tunnel)

ssh [email protected] 'python3 ~/scripts/homelab-ctl.py sync --yes'

Genera/valida/despliega managed.caddy en los dos nodos Caddy HA (LXC 270/271, .40/.41), y reconcilia el ingress CF Tunnel para que miapp.monxas.casa apunte al VIP 192.168.0.250 (Caddy hace el routing final por hostname desde managed.caddy). Ver Adding a service para el detalle del script (status/watch/update/smoke/remove).

Paso 3 — Elegir modo de protección

Modo A — Sin protección (la app tiene login propio)

No crees ninguna Access app. El servicio queda accesible con la auth de la app. Patrón de Immich (pics) y Audiobookshelf (podcasts) — OIDC/login propio. Nada más que hacer.

Modo B — CF Access + PocketID (UI protegida en el borde)

Para apps sin auth propia. Crea Access app + policy:

source ~/.env                       # CF_API_TOKEN, CF_ACCOUNT_ID
B="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps"

AID=$(curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
  --data "{\"name\":\"miapp\",\"domain\":\"miapp.monxas.casa\",\"type\":\"self_hosted\",\"session_duration\":\"24h\",\"app_launcher_visible\":false,\"auto_redirect_to_identity\":false}" \
  "$B" | jq -r .result.id)

curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
  --data "{\"name\":\"default\",\"decision\":\"allow\",\"include\":[{\"email\":{\"email\":\"[email protected]\"}},{\"email_domain\":{\"domain\":\"monxas.com\"}}]}" \
  "$B/$AID/policies"

Verificar: curl -s -o /dev/null -w "%{http_code}\n" https://miapp.monxas.casa/302 (login).

App sin policy = bloquea TODO

Crea app y policy seguidas. Una Access app sin policy deniega a todos.

Modo C — CF Access + bypass de API (apps móviles / webhooks)

Para servicios con UI web + una API que consume una app móvil o CLI que no puede hacer login OIDC interactivo (Subsonic/Amperfy, webhooks n8n, clientes arr…). Se crean DOS apps: bypass en el path de la API + protección* en el dominio. El path más específico gana precedencia → la API pasa libre y la UI queda gateada.

Alternativa a los Service Tokens: usa bypass por path cuando la app no sabe mandar cabeceras CF-Access-Client-Id/Secret (caso de Amperfy).

source ~/.env
B="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps"
mkapp(){ curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" --data "$1" "$B" | jq -r .result.id; }
mkpol(){ curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" --data "$2" "$B/$1/policies" >/dev/null; }

# 1) Bypass del path de la API (ajusta /rest al de tu app)
A1=$(mkapp "{\"name\":\"miapp-api-bypass\",\"domain\":\"miapp.monxas.casa/rest\",\"type\":\"self_hosted\",\"session_duration\":\"24h\",\"app_launcher_visible\":false,\"auto_redirect_to_identity\":false}")
mkpol "$A1" "{\"name\":\"bypass-everyone\",\"decision\":\"bypass\",\"include\":[{\"everyone\":{}}]}"

# 2) Protección del dominio (UI)
A2=$(mkapp "{\"name\":\"miapp\",\"domain\":\"miapp.monxas.casa\",\"type\":\"self_hosted\",\"session_duration\":\"24h\",\"app_launcher_visible\":false,\"auto_redirect_to_identity\":false}")
mkpol "$A2" "{\"name\":\"default\",\"decision\":\"allow\",\"include\":[{\"email\":{\"email\":\"[email protected]\"}},{\"email_domain\":{\"domain\":\"monxas.com\"}}]}"

Paths típicos a bypassear: Subsonic /rest · n8n /webhook · *arr /api.

Verificar ambos planos:

curl -s "https://miapp.monxas.casa/rest/ping.view?u=U&p=P&v=1.16.1&c=x&f=json"   # API → responde SIN login CF
curl -s -o /dev/null -w "%{http_code}\n" https://miapp.monxas.casa/               # UI → 302 (protegida)

Ejemplo real — music.monxas.casa (Navidrome + Amperfy, 2026-07-19)

  • Navidrome en stack media, label tunnel.hostname=music.monxas.casahomelab-ctl.py sync.
  • Modo C: bypass music.monxas.casa/rest (API Subsonic de Amperfy) + protección music.monxas.casa (UI, PocketID).
  • Verificado: /rest/ping.viewok sin login; / → 302 a cloudflareaccess.
  • Access apps: bypass ff1fed9c-d8c9-4b90-a8fe-57c6cd66c03f, protegida 343f3f41-1054-45c7-add0-0f2b21f513fb.

Ver también Cloudflare Access reference (lista de apps + recipes).

Quitar un subdominio

ssh [email protected] 'python3 ~/scripts/homelab-ctl.py remove miapp.monxas.casa'   # solo ingress CF Tunnel
# labels/entrada en tunnel-static.yaml quedan — bórralas a mano si no quieres que sync la vuelva a añadir
# + borrar Access apps: DELETE /accounts/$ACC/access/apps/<id>  (o dashboard CF)

tunnel-sync.sh (bash) — sin verificar si sigue en uso

Existe un script /home/monxas/tunnel-sync.sh en VM208 (14KB, modificado recientemente). El docstring de homelab-ctl.py dice que reemplaza a tunnel-sync.py (Python, ya borrado del disco) — no queda claro si tunnel-sync.sh es un tercer script en paralelo, un fork previo, o algo aparte. No aparece en ningún crontab revisado. TODO: confirmar con el operador si sigue vivo o es candidato a borrar.