# ============================================================= # GLOBAL OPTIONS # ============================================================= { acme_dns cloudflare {env.CF_API_TOKEN} } # ============================================================= # SNIPPETS # ============================================================= # Reusable matcher — blocks anything not on the LAN or tailnet. # Usage: import internal_only inside any site block. # 100.64.0.0/10 is the Tailscale CGNAT range — allows tailnet clients that # reach Caddy without subnet-route SNAT (i.e. --snat-subnet-routes=false). (internal_only) { @blocked not remote_ip 192.168.1.0/24 172.16.0.0/12 100.64.0.0/10 127.0.0.1 respond @blocked "Access denied" 403 } # Same IP gate as (internal_only), but for use INSIDE a route{} block. # Caddy sorts the `route` directive ahead of `respond`, so an `import # internal_only` sitting above a catch-all `route {}` is dead code — the route # matches everything and handles the request before the 403 is ever reached. # Inside a route, directives run in written order, so importing this as the # first line of the route makes the gate fire. Use this, not internal_only, # on any site that wraps its handlers in route{} (i.e. the Authentik sites). (internal_gate) { @blocked_ip not remote_ip 192.168.1.0/24 172.16.0.0/12 100.64.0.0/10 127.0.0.1 respond @blocked_ip "Access denied" 403 } # TB-006 — Authentik forward auth (embedded outpost on authentik-server:9000). # Wrap protected sites in `route { ... }` so bypass handles run before forward_auth. (authentik_outpost) { reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 } (authentik_forward_auth) { forward_auth authentik-server:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version trusted_proxies private_ranges } } # Clients on LAN, Docker, or tailnet (same ranges as internal_only). (remote_internal) { remote_ip 192.168.1.0/24 172.16.0.0/12 100.64.0.0/10 127.0.0.1 } # Shared-link gate — external users visit ?k= once to get a 30-day cookie. # Internal IPs and tailnet clients pass through unconditionally. # Usage: import share_gate (replaces import internal_only on shared services) # Uses {$SHARE_KEY} (parse-time substitution) — baked in at caddy reload/start. # SameSite=Lax (not Strict): Strict causes Safari/Firefox to drop the cookie on the # redirect when the top-level navigation originated from an external app (Discord). # Rotate: update SHARE_KEY in stack.env, push → Portainer redeploys → new key. (share_gate) { @external not remote_ip 192.168.1.0/24 172.16.0.0/12 100.64.0.0/10 127.0.0.1 handle @external { route { @has_key query k={$SHARE_KEY} handle @has_key { header Set-Cookie "share_access={$SHARE_KEY}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax" redir {http.request.uri.path} 302 } @has_cookie expression `{http.request.cookie.share_access} == "{$SHARE_KEY}"` handle @has_cookie { } handle { respond "Access denied" 403 } } } } # ============================================================= # FOUNDRY VTT — public # ============================================================= foundry.ginnoir.com { reverse_proxy foundry:30000 } foundry2.ginnoir.com { reverse_proxy foundry2:30000 } # ============================================================= # TABLETOP TOOLS — public # ============================================================= 5etools.ginnoir.com { reverse_proxy 5etools:80 } # ============================================================= # FILE STORAGE — internal only (Nextcloud) # ============================================================= files.ginnoir.com { # Public share links must resolve for external users; the rest of Nextcloud # stays LAN/tailnet-only. Can't use `import internal_only` here — this needs # the same IP gate with a path-based exemption, so the matcher is inlined. # Blocked = external client AND not a public-share path. The /core, /dist, # /css, /js and theming paths are the assets the share page itself loads; # without them an external visitor gets an unstyled, non-functional page. @blocked { not remote_ip 192.168.1.0/24 172.16.0.0/12 100.64.0.0/10 127.0.0.1 not path /s/* /index.php/s/* /public.php /public.php/* not path /apps/files_sharing/* /index.php/apps/files_sharing/* /ocs/v2.php/apps/files_sharing/* not path /core/* /dist/* /css/* /js/* /themes/* /apps/theming/* /index.php/apps/theming/* } respond @blocked "Access denied" 403 redir /.well-known/carddav /remote.php/dav 301 redir /.well-known/caldav /remote.php/dav 301 reverse_proxy nextcloud:80 { header_up X-Forwarded-Proto https header_up X-Real-IP {remote_host} } } # ============================================================= # STATIC SITES — public # ============================================================= wa4.ginnoir.com { root * /srv/wa4 file_server } # ============================================================= # MEDIA REQUESTS — public # ============================================================= requests.ginnoir.com { reverse_proxy seerr:5055 } # ============================================================= # STREAMING — public (replaces Plex's own remote access) # ============================================================= jellyfin.ginnoir.com { reverse_proxy jellyfin:8096 } wizarr.ginnoir.com { reverse_proxy wizarr:5690 } # ============================================================= # MEDIA MANAGEMENT — internal only # ============================================================= sonarr.ginnoir.com { route { import internal_gate import authentik_outpost handle /api/* { reverse_proxy sonarr:8989 } handle { import authentik_forward_auth reverse_proxy sonarr:8989 } } } radarr.ginnoir.com { route { import internal_gate import authentik_outpost handle /api/* { reverse_proxy radarr:7878 } handle { import authentik_forward_auth reverse_proxy radarr:7878 } } } bazarr.ginnoir.com { route { import internal_gate import authentik_outpost handle /api/* { reverse_proxy bazarr:6767 } handle { import authentik_forward_auth reverse_proxy bazarr:6767 } } } prowlarr.ginnoir.com { route { import internal_gate import authentik_outpost handle /api/* { reverse_proxy prowlarr:9696 } handle { import authentik_forward_auth reverse_proxy prowlarr:9696 } } } tautulli.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy tautulli:8181 } } } jellystat.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy jellystat:3000 } } } # ============================================================= # DOWNLOAD CLIENTS — internal only # ============================================================= qbittorrent.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy qbittorrent:3232 } } } nzbget.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy nzbget:6789 } } } whisparr.ginnoir.com { route { import internal_gate import authentik_outpost handle /api/* { reverse_proxy whisparr:6969 } handle { import authentik_forward_auth reverse_proxy whisparr:6969 } } } stash.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy stash:6970 } } } # ============================================================= # GAMES / ROMS — internal only # External access for a trusted friend is via the `share` stack's Tailscale # serve node (roms-share..ts.net), NOT public exposure here. # ============================================================= roms.ginnoir.com { import share_gate reverse_proxy romm:8080 } romhacks.ginnoir.com { import share_gate root * /srv/romhacks-wiki file_server } jd.ginnoir.com { import internal_only reverse_proxy jdownloader:5800 } idlegame.ginnoir.com { import internal_only root * /srv/idlegame try_files {path} /index.html file_server } # Pokémon ROM-hack library files (box art, guides, spreadsheets) for the # Obsidian catalog notes. Internal-only — these downloads are private. # Root is the read-only /storage1/labdata/romhacks/library bind from the proxy # stack; notes embed art via https://romhacks-files.ginnoir.com//. romhacks-files.ginnoir.com { import internal_only handle_path /_roms/* { root * /srv/roms file_server } handle { root * /srv/romhacks file_server browse } } # ============================================================= # NOTIFICATIONS & RSS — public (ntfy native auth; FreshRSS HTTP auth via Authentik) # ============================================================= ntfy.ginnoir.com, http://ntfy.ginnoir.com { reverse_proxy ntfy:80 @httpget { protocol http method GET path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/) } redir @httpget https://{host}{uri} } freshrss.ginnoir.com { reverse_proxy freshrss:80 } # ============================================================= # RESUME / PORTFOLIO — public (native OIDC via Authentik; no forward_auth) # ============================================================= resume.ginnoir.com { reverse_proxy app:3000 } j-costa.com, https://j-costa.com { tls { issuer acme { disable_tlsalpn_challenge } } redir * https://resume.ginnoir.com/ginnoir/resume permanent } storage.j-costa.com, https://storage.j-costa.com { tls { issuer acme { disable_tlsalpn_challenge } } reverse_proxy resume-minio:9000 } # ============================================================= # MINIO CONSOLE — internal only # ============================================================= minio.ginnoir.com { import internal_only reverse_proxy resume-minio:9001 } # ============================================================= # FAMAPP & AUTH — public # ============================================================= fam.ginnoir.com { reverse_proxy famapp:3000 } auth.ginnoir.com { reverse_proxy authentik-server:9000 } dev.ginnoir.com { reverse_proxy 192.168.1.74:3000 } # ============================================================= # MANAGEMENT — internal only # ============================================================= portainer.ginnoir.com { @webhook { method POST path /api/stacks/webhooks/* } handle @webhook { reverse_proxy portainer:9000 } handle { reverse_proxy portainer:9000 } } uptime.ginnoir.com { route { import authentik_outpost @uptime_mcp { path /api/* /socket.io/* import remote_internal } handle @uptime_mcp { reverse_proxy uptime-kuma:3001 } handle { import authentik_forward_auth reverse_proxy uptime-kuma:3001 } } } homarr.ginnoir.com { import internal_only reverse_proxy homarr:7575 } # Hermes Desktop remote backend. Only API/WS is exposed for native # clients; the browser dashboard is blocked so its injected session token # is not leaked to the public web. hermes.ginnoir.com { route { handle /api/* { reverse_proxy 172.20.0.1:9119 { header_up Host 172.20.0.1:9119 } } handle { respond "Hermes dashboard browser UI is disabled on this public hostname." 403 } } } # Hermes WebUI for browser/phone access to the valhalla Hermes runtime. # Internal-only; WebUI also enforces its own password auth. webui.ginnoir.com { import internal_only reverse_proxy 172.20.0.1:8787 } # ComfyUI node editor — inference runs on the Mac at 192.168.1.121 (Metal/MPS). # LAN/tailnet only; no Authentik (WebSocket queue/progress breaks under forward_auth). # # The explicit `resolvers` below is what made DNS-01 work here, not the imgstudio # hostname (an earlier comment blamed a "false .com" in comfyui.ginnoir.com — # that was wrong). The LAN resolver returns NODATA for `SOA ginnoir.com`, so # certmagic's zone lookup climbs to `com.` and Cloudflare rejects it. The caddy # container now pins public resolvers stack-wide (stacks/proxy/docker-compose.yml), # making this block redundant; kept as belt-and-braces. imgstudio.ginnoir.com { import internal_only tls { dns cloudflare {env.CF_API_TOKEN} resolvers 1.1.1.1 } reverse_proxy 192.168.1.121:8188 } backup.ginnoir.com { route { import authentik_outpost handle { import authentik_forward_auth reverse_proxy kopia:51515 } } } router.ginnoir.com { import internal_only reverse_proxy 192.168.1.1 } # ============================================================= # DEV STACK — internal only # ============================================================= gitea.ginnoir.com { @api path /api/* @git path_regexp (?i)\.git(/|$) handle @api { reverse_proxy gitea:3000 } handle @git { reverse_proxy gitea:3000 } handle { reverse_proxy gitea:3000 } } code.ginnoir.com { route { import authentik_outpost handle { import authentik_forward_auth reverse_proxy code_server:8443 } } } registry.ginnoir.com { import internal_only reverse_proxy registry:5000 } registry-ui.ginnoir.com { route { import authentik_outpost handle { import authentik_forward_auth reverse_proxy registry_ui:80 } } } dbx.ginnoir.com { route { import authentik_outpost handle { import authentik_forward_auth reverse_proxy dbx:4224 } } } vault.ginnoir.com { route { import authentik_outpost @vaultapi path /v1/* handle @vaultapi { reverse_proxy vault:8200 } handle { import authentik_forward_auth reverse_proxy vault:8200 } } } docs.ginnoir.com { import internal_only reverse_proxy bookstack:80 } plane.ginnoir.com { route { import authentik_outpost @plane_api { path /api/* header X-API-Key * } handle @plane_api { reverse_proxy plane_api:8000 } handle /api/* { import authentik_forward_auth reverse_proxy plane_api:8000 } handle /auth/* { import authentik_forward_auth reverse_proxy plane_api:8000 } handle /god-mode/* { import authentik_forward_auth reverse_proxy plane_admin:3000 } handle { import authentik_forward_auth reverse_proxy plane_web:3000 } } } # ============================================================= # FILES / DOCUMENTS — internal only # files.ginnoir.com is the primary domain; nextcloud.ginnoir.com is an alias. # office.ginnoir.com serves the OnlyOffice document server. # ============================================================= nextcloud.ginnoir.com { import internal_only redir /.well-known/carddav /remote.php/dav 301 redir /.well-known/caldav /remote.php/dav 301 reverse_proxy nextcloud:80 { header_up X-Forwarded-Proto https header_up X-Real-IP {remote_host} } } office.ginnoir.com { import internal_only reverse_proxy onlyoffice-docs:80 } # ============================================================= # PHOTOS — internal only # ============================================================= photos.ginnoir.com { import internal_only reverse_proxy immich-server:2283 } # ============================================================= # NOTES / PKM — internal only (LiveSync over LAN or tailnet) # ============================================================= obsidian.ginnoir.com { import internal_only reverse_proxy couchdb:5984 }