Files
homelabstack/Caddyfile
T
ginnoirandClaude Opus 5 f70963e166
Deploy Caddyfile to valhalla / deploy (push) Successful in 1m32s
fix(proxy): pin public resolvers on caddy so ACME DNS-01 works
The LAN resolver (OPNsense Unbound) is authoritative for ginnoir.com via the
split-horizon override, but its local-zone holds only A records, so
`SOA ginnoir.com` returns NODATA. certmagic's zone lookup walks up the label
chain hunting for an SOA, finds none at ginnoir.com, climbs to `com.`, and asks
Cloudflare for a `com` zone:

  adding temporary record for zone "com.": expected 1 zone, got 0 for com.

Every DNS-01 renewal has failed for ~17 days (attempt 91 on the oldest), and
Caddy fell back to the LE staging endpoint. Certs began expiring as they rolled
off: 5etools (-4d), files (-1.9d), auth (-15.6h), fam (-15.6h), with ~20 more
queued behind them.

The expired auth.ginnoir.com cert is what broke Nextcloud SSO: its server-side
discovery fetch fails TLS verification ("certificate has expired"), and
user_oidc's LoginController catches that and returns 404 "provider unreachable".
Browsers were unaffected because they let you click through an expired cert;
PHP/curl does not.

Pinning 1.1.1.1/1.0.0.1 on the caddy container sends the SOA lookup to public
DNS. Verified Docker's embedded resolver still serves container names and the
gitea.ginnoir.com alias, which take priority over the external servers.

Also corrects the imgstudio comment, which credited its working TLS to avoiding
a "false .com" in the hostname. The actual reason was its explicit `resolvers`
line -- the same fix, applied to one site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:51:54 -05:00

570 lines
14 KiB
Caddyfile

# =============================================================
# 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
}
# 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=<SHARE_KEY> 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 {
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}
}
}
# =============================================================
# STATIC SITES — public
# =============================================================
wa4.ginnoir.com {
root * /srv/wa4
file_server
}
# =============================================================
# MEDIA REQUESTS — public
# =============================================================
requests.ginnoir.com {
reverse_proxy seerr:5055
}
# =============================================================
# MEDIA MANAGEMENT — internal only
# =============================================================
sonarr.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle /api/* {
reverse_proxy sonarr:8989
}
handle {
import authentik_forward_auth
reverse_proxy sonarr:8989
}
}
}
radarr.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle /api/* {
reverse_proxy radarr:7878
}
handle {
import authentik_forward_auth
reverse_proxy radarr:7878
}
}
}
bazarr.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle /api/* {
reverse_proxy bazarr:6767
}
handle {
import authentik_forward_auth
reverse_proxy bazarr:6767
}
}
}
prowlarr.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle /api/* {
reverse_proxy prowlarr:9696
}
handle {
import authentik_forward_auth
reverse_proxy prowlarr:9696
}
}
}
tautulli.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle {
import authentik_forward_auth
reverse_proxy tautulli:8181
}
}
}
# =============================================================
# DOWNLOAD CLIENTS — internal only
# =============================================================
qbittorrent.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle {
import authentik_forward_auth
reverse_proxy qbittorrent:3232
}
}
}
nzbget.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle {
import authentik_forward_auth
reverse_proxy nzbget:6789
}
}
}
whisparr.ginnoir.com {
import internal_only
route {
import authentik_outpost
handle /api/* {
reverse_proxy whisparr:6969
}
handle {
import authentik_forward_auth
reverse_proxy whisparr:6969
}
}
}
stash.ginnoir.com {
import internal_only
route {
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.<tailnet>.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/<slug>/<file>.
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
}