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:
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):
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, labeltunnel.hostname=music.monxas.casa→homelab-ctl.py sync. - Modo C: bypass
music.monxas.casa/rest(API Subsonic de Amperfy) + protecciónmusic.monxas.casa(UI, PocketID). - Verificado:
/rest/ping.view→oksin login;/→ 302 acloudflareaccess. - Access apps: bypass
ff1fed9c-d8c9-4b90-a8fe-57c6cd66c03f, protegida343f3f41-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.