# 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 (Anubis rollout, 2026-08-28): the guarded vhosts # (homepage, SearXNG, Hermes, Prometheus, RomM, Copyparty) proxy EVERYTHING # through the Anubis bot-challenge proxy first. Chain: client → Caddy (TLS, # public listener) → Anubis :8923 (verify: DENY known AI scrapers, CHALLENGE # everyone else) → Caddy's internal listener http://:8016 (Authentik # forward_auth + app routing resumes; Host headers survive both hops) → app. # Apps with their own auth (Grafana, Gitea, OpenWebUI, ComfyUI, 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. The basicauth hash is a # placeholder; deploy.sh renders the real hash (CADDY_BASICAUTH_HASH from the # 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://franksdank.cloud/ 302 } # --- Homepage (Anubis → Authentik SSO) --- franksdank.cloud www.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Legacy name: the old homepage name redirects to the naked domain --- dankclaw.franksdank.cloud { redir https://franksdank.cloud/ 302 } # --- Legacy name: the media host has no Caddy of its own anymore --- dankserver.franksdank.cloud { redir https://franksdank.cloud/ 302 } # --- 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} } } # --- ComfyUI --- comfyui.franksdank.cloud { reverse_proxy comfyui:8188 } # --- OpenWebUI (quadlet on slirp4netns; published 3005) --- chat.franksdank.cloud { reverse_proxy 192.168.0.143:3005 } # --- Hermes --- hermes.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- 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 --- grafana.franksdank.cloud { basicauth { dankuser REPLACE_WITH_HASH } reverse_proxy grafana:3000 } # --- Prometheus --- prometheus.franksdank.cloud { reverse_proxy anubis:8923 { header_up X-Real-Ip {remote_host} } } # --- Loki --- loki.franksdank.cloud { reverse_proxy loki:3100 } # --- Pi-hole (quadlet on slirp4netns; published 8083) --- pihole.franksdank.cloud { basicauth { dankuser REPLACE_WITH_HASH } reverse_proxy 192.168.0.143:8083 } # ═══════════════════════════════════════════════════════════════ # 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 --- jellyfin.franksdank.cloud { reverse_proxy 192.168.0.235:8096 { transport http { versions h2c 1.1 } } } # --- *arr Stack --- radarr.franksdank.cloud { reverse_proxy 192.168.0.235:7878 } sonarr.franksdank.cloud { reverse_proxy 192.168.0.235:8989 } lidarr.franksdank.cloud { reverse_proxy 192.168.0.235:8686 } readarr.franksdank.cloud { reverse_proxy 192.168.0.235:8787 } prowlarr.franksdank.cloud { reverse_proxy 192.168.0.235:9696 } qbittorrent.franksdank.cloud { reverse_proxy 192.168.0.235:8080 } # --- Internal listener: where Anubis forwards accepted traffic --- # Anubis dials http://caddy:8016 after verifying a client (TARGET in # dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop, # so per-vhost routing resumes here: outpost paths go straight to the # Authentik outpost, everything else runs the forward_auth check and then # dials the app. This block is the second half of every guarded vhost. http://:8016 { @home host franksdank.cloud www.franksdank.cloud handle @home { root * /srv/homepage route { # Authentik outpost (login UI + session management). reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 # Forward auth: check the Authentik session before serving the page. forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } # Operator-only admin page. Auth is checked on the original path, # before any rewrite, so the path matcher is reliable. 'handle' # preserves order. handle /admin* { try_files /admin.html file_server } # Shared data + assets: fetched by both the user page and the admin # page. With forward_auth, any authenticated user can access them # (the browser's Authentik session covers these fetches). @data path /services.json /status.json /app.js /style.css /deployed.js handle @data { file_server } # Everything else (the user page). handle { try_files /index.html file_server } } } @search host search.franksdank.cloud handle @search { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } reverse_proxy 192.168.0.143:8081 } @hermes host hermes.franksdank.cloud handle @hermes { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } reverse_proxy hermes:8090 } @prometheus host prometheus.franksdank.cloud handle @prometheus { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } reverse_proxy prometheus:9090 } @romm host romm.franksdank.cloud handle @romm { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } reverse_proxy 192.168.0.235:8085 } @files host files.franksdank.cloud handle @files { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name trusted_proxies private_ranges } reverse_proxy 192.168.0.235:3923 } } # --- Optional: Jackett --- # jackett.franksdank.cloud { # reverse_proxy 192.168.0.235:9117 # } # --- Optional: Whisparr --- # whisparr.franksdank.cloud { # reverse_proxy 192.168.0.235:6969 # }