caddy¶
Module: system/services/caddy.nix
The reverse proxy for every exposed service, under *.<domain>, with a WILDCARD certificate from
Let's Encrypt through the DNS-01 challenge.
It restores the ingress that existed on Arch and that the migration to NixOS left behind, now declarative.
One wildcard site block, not a vhost per subdomain¶
That is ONE certificate instead of ~10 simultaneous ACME requests. Routing by name comes from the
@host matchers, and an unmapped subdomain falls into the 404 at the end.
DNS-01 and not HTTP-01 because a wildcard is only issued through DNS-01. The price is a Caddy
built with the dns.providers.cloudflare plugin. On Arch that meant building with xcaddy and
HIDING the binary in /usr/local/bin, because a pacman -Syu once overwrote the custom binary and
took the whole proxy down. On Nix the package IS the declaration, so that problem stops existing.
The hash in the derivation belongs to the Go vendor and changes when either the Caddy version or
the plugin changes. A rebuild complaining about it means recomputing with lib.fakeHash and
reading the expected value in the error.
propagation_timeout -1 is not a guess¶
certmagic's LOCAL propagation check fails ON THIS host even when forcing public resolvers, while the record does propagate for real: 8.8.8.8, 1.1.1.1 and the authoritative server all confirm it, and it is the world that LE queries.
A fixed 30s wait with the local check turned off makes LE validate directly. Without it the issuance HANGS.
The vhosts are GENERATED from the ingress SSOT¶
The Caddyfile stopped being written by hand: what decides reach is
system/net/ingress.nix, where forgetting to declare CLOSES
instead of exposing, because the expose default is lan.
Concatenation and not interpolation when emitting {$VAR}: "{$" + v + "}" is the only
unambiguous form, because $${v} in a Nix string is a SYNTAX ERROR, not an escape. It bit once:
the first generated file came out with a literal {$${v}}, which would become an empty hash at
runtime.
The auto-gate, and why the secret list is derived¶
The service only activates when all FOUR secrets exist. Until they are provisioned it stays INERT
and the system keeps building, which matters because an empty {$VAR} would become an empty
basic_auth hash and Caddy would refuse the ENTIRE config.
The hashes are not literals in the gate, they are derived from whoever declares auth in the
SSOT. When they were fixed, removing basic_auth from a service left the gate requiring a secret
nobody read anymore, and the day that item left Bitwarden, Caddy would go inert and take jellyfin,
torrent, ai and duo down with it. A service that goes down because of a password it does not use is
the worst kind of coupling: invisible until the day it matters.
The same reasoning applies to the .env: leaving a CADDY_*_HASH there after removing the
basic_auth renders a secret nothing consumes, and the next reader cannot tell it is leftover.
Single quotes around the hashes in the .env: bcrypt contains $, and the old setup's file
already documented that without them the value is mangled before reaching the process.
client_ip and not remote_ip, before the trap exists¶
The "home" matcher is the LAN plus the router's WireGuard plus loopback, defined once and reused by every handle.
The 10.10.10.0/24 works because the WireGuard server is the ROUTER and the wg-to-lan path does
not NAT, so the source IP arrives preserved. If WireGuard ever moves to the host, that range moves
with it.
Today client_ip and remote_ip are identical, since with no trusted proxy the client IS the
connection. The difference shows up with cloudflared, which delivers over LOOPBACK: with
remote_ip, all the tunnel traffic would become "home" and bypass basic_auth SILENTLY. Using
client_ip now costs nothing and removes the trap before it exists.
Do NOT add trusted_proxies while there is no tunnel. Without it the X-Forwarded-For header
is ignored, which is what we want; with it, any local process could start forging the source IP.
Two things that changed with Tailscale leaving (08/08/2026): this range is now what REMOTE ACCESS
goes through, and the old 100.64.0.0/10 (the tailnet) went out, which is just as well, because
that range is the SAME one carrier CGNAT uses, so an external client behind an ISP NAT could
present an address from it and be treated as home.
On Arch this list was duplicated and DIVERGENT: duo included the WireGuard range and ai did
not, from a forgotten backfill. Unifying it EXTENDS ai's access to the VPN clients, which is a
conscious decision and not a side effect.
The fail2ban jail lives here on purpose¶
It is in this module and not next to the service because the jail only exists because of this proxy: whoever deletes the vhost has to delete the jail with it, and the coupling stays visible.
basic_auth only returns 401 when the password is wrong, so a 401 on a gated host is a failed
attempt and not normal browsing. The hosts come DERIVED from the SSOT, so declaring a new service
with auth already enters the jail with no hand-edited failregex.
With no host having basic_auth there is no jail at all, and that is deliberate: an empty
authHostsRe would generate "host":"()", a failregex that matches nothing. The jail would stay
green in fail2ban-client status while protecting nothing. Better not to exist than to exist
lying.
That is also why it uses optionalAttrs and not mkIf on settings: the fail2ban module injects
enabled = true into every DECLARED jail, so emptying the settings would still emit the section.
The filters depend on the ORDER OF THE KEYS in the JSON access log (remote_ip, then host, then
status, with a non-greedy .+?). A Caddy bump can break them SILENTLY: the service stays up and
simply stops banning. Validate with fail2ban-regex on every bump. The log has to be format json
to stderr, which is where the jail reads from.
The first allowedTCPPorts in the repo¶
Everything else uses the upstream module's openFirewall, but services.caddy does not have one.
The router has been forwarding 80/443/2222 since the old setup, and what blocked was the NixOS
firewall, on by default. 80 stays open because Caddy redirects to 443, and because one day it might
be needed for the HTTP-01 of a domain without DNS-01.
The loopback port map¶
A new project picks a free one and WRITES IT DOWN here, otherwise the next collision is silent.
| Port | Service | Port | Service |
|---|---|---|---|
| 3000 | open-webui | 3001 | spendflow |
| 3003 | homepage | 3004 | filebrowser |
| 3005 | housing-radar | 3006 | GRAD-RADAR (front) |
| 3007 | CREDIT-RADAR (front) | 8007 | CREDIT-RADAR (api) |
| 3010 | duo-web | 8000 | spendflow-api (reserved) |
| 8006 | GRAD-RADAR (api) | 8010 | duo-api |
| 8080 | qbittorrent | 8096 | jellyfin |
| 11434 | ollama |
Turning it on, once¶
- Cloudflare: an API token with
Zone:Read + DNS:EditON THE ZONE. Separate from the DDNS token. caddy hash-passwordonce per basic_auth user.- Bitwarden: create the items, with the VALUE always in the password field:
Caddy ACME Email,Caddy Cloudflare DNS,Caddy Pos Hash v1cferr,Caddy Pos Hash jp. - Add the four lines to
secrets/bitwarden-secrets.json. sync-secrets, then rebuild.- DNS on Cloudflare:
pos.<domain>as a CNAME tossh.<domain>, proxied=false. ThesshA record is the IP anchor, maintained by the DDNS (see network).
State¶
/var/lib/caddy holds the ACME account and the certificates. It is not declared, and it is worth
backing up, because LE limits 5 DUPLICATE certificates per week: losing the store costs a reissue
window, not just a rebuild.
The Caddyfile has ONE OWNER, Nix (rule 14), so never a caddy reload writing over it.