# Homelab Caddyfile — the single Caddy for both hosts. # # Caddy runs as a container on dankclaw (192.168.0.143, this repo's # compose.yaml service 'caddy') and fronts every service on both hosts: # - dankclaw services: by container name (compose network) or by the # published LAN port (quadlet services on isolated networks) # - dankserver services (192.168.0.235): by published LAN port # # Naming: flat franksdank.cloud scheme (see serverAppConfigs-docs/Infrastructure/LAN-DNS.md). # DNS for these names is served by the Pi-hole quadlet on this host # (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143) # and handed to LAN clients by the SURFboard G36 router. # # Authentication chain (2026-08-29): the guarded vhosts (homepage, SearXNG, # Prometheus, RomM, Copyparty) proxy EVERYTHING through the Anubis # bot-challenge proxy first, then the whole request is proxied THROUGH # Authentik (2026.8 proxy-provider mode: this build serves no forward_auth # endpoint; the authentication flow runs inline — unauthenticated clients get # the login UI served in place, and once a session exists Authentik proxies # the request on to the app via the provider's internal_host). Chain: client → # Caddy (TLS, public listener) → Anubis :8923 (verify: DENY known AI scrapers, # CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth # decision; Host headers survive every hop) → authenticated traffic is # forwarded back to http://:8017 and routed straight to the app. # Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the # Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit # behind a challenge) are exempt by construction: their vhosts never route # through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left # it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit # behind the guard chain; Grafana authenticates users itself via the OAuth2 # code flow, so no identity header is forwarded to it. # The pihole vhost keeps its basicauth edge; the hash placeholder below is # rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into # /home/dank/Containers/caddy/Caddyfile, which the container mounts. # # TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix, # so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via # dns-01 through the Porkbun module built into the franksdank/caddy-porkbun # image (caddy/Containerfile). That needs no inbound ports and works from the # LAN, where Pi-hole resolves these names to 192.168.0.143. { # Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the # port is not published, so nothing outside the container can reach it. # It powers the config file watcher, so a rendered-config change # hot-reloads without a container restart. 'admin off' once left a broken # basicauth hash sitting on disk unapplied for hours (2026-08-22). email admin@franksdank.cloud # Solve ACME challenges for every site via Porkbun DNS (no port 80/443 # reachability needed). Credentials come from the container environment # (compose.yaml, from the host .env — never committed). acme_dns porkbun { api_key {env.PORKBUN_API_KEY} api_secret_key {env.PORKBUN_API_SECRET_KEY} } } # --- Bare-IP access (health checks, convenience) --- :80 { redir https://www.franksdank.cloud/ 308 } # --- Homepage (Anubis → Authentik SSO) --- # www holds the homepage; the naked domain redirects onto it at the TLS edge. # A redirect needs no challenge, so unauthenticated naked traffic never enters # the auth chain. The proxy provider's external_host must match the host that # actually carries traffic (https://www.franksdank.cloud) or Authentik matches # zero providers and answers its error page instead. franksdank.cloud { redir https://www.franksdank.cloud/ 308 } www.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Legacy name: the old homepage name redirects straight onto www --- dankclaw.franksdank.cloud { redir https://www.franksdank.cloud/ 308 } # --- Legacy name: the media host has no Caddy of its own anymore --- dankserver.franksdank.cloud { redir https://www.franksdank.cloud/ 308 } # --- Authentik (SSO / OIDC provider) --- # Exempt by construction: the IdP login flow must be reachable without a # challenge, so this vhost never routes through Anubis. auth.franksdank.cloud { reverse_proxy authentik-server:9000 } # ═══════════════════════════════════════════════════════════════ # Services on this host (dankclaw, 192.168.0.143) # ═══════════════════════════════════════════════════════════════ # --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) --- search.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- OpenWebUI (quadlet on slirp4netns; published 3005) --- ai.franksdank.cloud { reverse_proxy 192.168.0.143:3005 } # --- Gitea (quadlet; published 3000) --- # Exempt by construction: Gitea has its own login, and git smart-HTTP # clients (git push/fetch over HTTPS) cannot solve JS challenges. The # policy would need a bypass for /info/refs + /git-*-pack paths if this # ever moves behind Anubis. gitea.franksdank.cloud { reverse_proxy 192.168.0.143:3000 } # --- Grafana (guarded: Anubis → Authentik outpost gate; login inside Grafana) --- # Auth model = OAuth2 relying party against authentik (OAuth2Provider "grafana", # confidential client). The guard chain gates unauthenticated traffic; the # outpost carries no provider token in this deployment, so identity headers # never reach Grafana — RP mode needs none and works regardless of topology. # The basicauth edge this replaces (CADDY_BASICAUTH_HASH) is retired. grafana.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Prometheus --- prometheus.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Loki (guarded: Anubis → Authentik; loki-proxy provider, internal_host caddy:8017) --- loki.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Seerr (guarded: Anubis → Authentik; seerr provider, internal_host caddy:8017) --- seerr.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Marreta (guarded: Anubis → Authentik; marreta provider, internal_host caddy:8017) --- # Content-cleaning proxy. Any authentik-authenticated user may use it (no # group gate). Egress rides the searxng gluetun tunnel; reached via the # host-published port 8082 (gluetun netns), not by container name. marreta.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Pi-hole (quadlet on slirp4netns; published 8083) --- pihole.franksdank.cloud { basicauth { dankuser REPLACE_WITH_HASH } reverse_proxy 192.168.0.143:8083 } # --- Hermes WebUI (Hermex webui; host-netns container, published 8787) ------ # Exempt by construction: the webui authenticates itself (HERMES_WEBUI_PASSWORD), # so no Anubis challenge and no Authentik proxy provider is required — nothing # here enters the http://:8016 auth chain. Internal-only by DNS absence: there is # no public Porkbun A record for this name, so only LAN clients (Pi-hole # addn-hosts → 192.168.0.143) can resolve it at all; DNS-01 issuance via the # Porkbun module needs no public record. Upstream is `host.containers.internal` # — netavark's host address, 169.254.1.2 — NOT the host LAN IP: hermes-webui is # `Network=host`, so 8787 is a host-netns bind and not a published port, and # netavark DNATs only published ports (192.168.0.143:9119, the dashboard below, # answers from this bridge; 192.168.0.143:8787 does not). Measured live # 2026-09-16 inside the Caddy container: 192.168.0.143:8787 and 10.89.1.1:8787 # both REFUSED, host.containers.internal:8787 → 200. hermes.franksdank.cloud { reverse_proxy host.containers.internal:8787 } # --- Hermes dashboard (admin UI; published 9119 on dankclaw_default) -------- # Exempt by construction: the app enforces its own complete auth — verified live # from Caddy's own network, an unauthenticated GET / returns 302 → /login?next=%2F # (uvicorn), and basic_auth.username + a scrypt password_hash are set in the # dashboard's /data/config.yaml. No outpost provider needed. hermesadmin.franksdank.cloud { reverse_proxy 192.168.0.143:9119 } # ═══════════════════════════════════════════════════════════════ # Services on dankserver (192.168.0.235) — published LAN ports # ═══════════════════════════════════════════════════════════════ # --- Plex (existing, kept) --- # http:// on purpose: franksdank.casa is not hosted in the same Porkbun # account as franksdank.cloud, so the DNS-01 provider cannot issue a # Let's Encrypt cert for it. It stays plain-HTTP legacy; the canonical # name is plex.franksdank.cloud (HTTPS, below). http://plex.franksdank.casa { reverse_proxy 192.168.0.235:32400 { transport http { tls_insecure_skip_verify } } } # PMS requires TLS from every non-loopback client: plain-HTTP off-loopback # is read and silently closed (diagnosed 2026-08-26), which surfaced as an # empty-body 502 here. Dial the upstream over TLS; PMS's self-signed cert is # skipped on this pinned-LAN hop only (client-facing LE TLS is untouched). # The web UI lives at /web/; / returns the XML MediaContainer index. plex.franksdank.cloud { reverse_proxy 192.168.0.235:32400 { transport http { tls_insecure_skip_verify } } } # --- Calibre-Web --- books.franksdank.cloud { reverse_proxy 192.168.0.235:8083 } # --- RomM --- romm.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Copyparty --- files.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Jellyfin --- jelly.franksdank.cloud { reverse_proxy 192.168.0.235:8096 } # --- *arr Stack (exempt by construction: own auth) --- prowlarr.franksdank.cloud { reverse_proxy 192.168.0.112:9696 } radarr.franksdank.cloud { reverse_proxy 192.168.0.112:7878 } # --- Sonarr: internal-only by construction (retired 2026-09-04) ----------- # No vhost and no public DNS record for sonarr.franksdank.cloud: the TV-library # admin UI is reachable only on the LAN at 192.168.0.112:8989 (users request TV # through Seerr, which sits behind the guard chain; admins reach Sonarr's own # UI directly). A brief unguarded public vhost lived here and served the # zero-auth UI to the internet via manual DNS — retired. lidarr.franksdank.cloud { reverse_proxy 192.168.0.112:8686 } readarr.franksdank.cloud { reverse_proxy 192.168.0.112:8787 } qbittorrent.franksdank.cloud { reverse_proxy 192.168.0.112:8080 } # --- Internal listener: auth decision layer --------------------------------- # Anubis dials http://caddy:8016 after verifying a client (TARGET in # dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop. # Every request here is proxied whole-request to Authentik with Host # preserved. This build's embedded outpost routes ONLY on the ORIGINAL Host # against each proxy provider's external_host — meta headers are ignored and # cross-host absolute next= redirects are rejected by the flow's open-redirect # guard ("Request has been denied: Invalid next URL"), verified live. # Unauthenticated clients get the login flow served in place; authenticated # sessions are forwarded by the provider's internal_host back to # http://:8017 and routed straight to the app. Every host that may enter this # listener MUST have a proxy provider registered with the embedded outpost — # the Anubis guard set, the Caddy TLS edge, and the provider list stay in # lockstep. Exempt vhosts never enter this block. http://:8016 { reverse_proxy authentik-server:9000 { header_up X-Forwarded-Proto https } } # --- Post-auth listener: where Authentik forwards authenticated traffic ---- # The proxy provider's internal_host is http://caddy:8017, so every request # that passes authentication lands here (Host header preserved) and routes # straight to its app. This is the end of the chain — nothing dials back # into Authentik from here, so the chain cannot loop. http://:8017 { @home host www.franksdank.cloud handle @home { root * /srv/homepage # Deploys overwrite /srv/homepage in place; without revalidation a # browser keeps serving the pre-deploy CSS/JS/HTML. no-cache forces an # ETag round-trip on every load (a cheap 304 when unchanged). header Cache-Control "no-cache" # RBAC (2026-09-01): /admin requires membership in the homepage-admins # authentik group. In proxy-provider mode the outpost injects # X-Authentik-Groups on its upstream request to this listener and strips # any client-supplied X-Authentik-* headers first, so browsers cannot # spoof it. Regex is a substring match, insensitive to the group-list # separator. handle /admin* { @adminok header_regexp X-Authentik-Groups homepage-admins handle @adminok { try_files /admin.html file_server } respond "Forbidden: homepage admins only" 403 } # User docs page (static). Anchors (#models etc.) are client-side, # so both /docs and /docs/ map onto the single docs.html. handle /docs { try_files /docs.html file_server } handle /docs/ { try_files /docs.html file_server } @data path /services.json /status.json /app.js /style.css /deployed.js handle @data { file_server } handle { try_files /index.html file_server } } @search host search.franksdank.cloud handle @search { reverse_proxy 192.168.0.143:8081 } @loki host loki.franksdank.cloud handle @loki { reverse_proxy loki:3100 } @seerr host seerr.franksdank.cloud handle @seerr { reverse_proxy seerr:5055 } @grafana host grafana.franksdank.cloud handle @grafana { reverse_proxy grafana:3000 } @prometheus host prometheus.franksdank.cloud handle @prometheus { reverse_proxy prometheus:9090 } @romm host romm.franksdank.cloud handle @romm { reverse_proxy 192.168.0.235:8085 } @files host files.franksdank.cloud handle @files { reverse_proxy 192.168.0.235:3923 } @prowlarr host prowlarr.franksdank.cloud handle @prowlarr { reverse_proxy 192.168.0.112:9696 } @radarr host radarr.franksdank.cloud handle @radarr { reverse_proxy 192.168.0.112:7878 } @lidarr host lidarr.franksdank.cloud handle @lidarr { reverse_proxy 192.168.0.112:8686 } @readarr host readarr.franksdank.cloud handle @readarr { reverse_proxy 192.168.0.112:8787 } @qbittorrent host qbittorrent.franksdank.cloud handle @qbittorrent { reverse_proxy 192.168.0.112:8080 } @marreta host marreta.franksdank.cloud handle @marreta { reverse_proxy 192.168.0.143:8082 } } # --- Optional: Jackett --- # jackett.franksdank.cloud { # reverse_proxy 192.168.0.235:9117 # } # --- Optional: Whisparr --- # whisparr.franksdank.cloud { # reverse_proxy 192.168.0.235:6969 # }