5.0 KiB
Caddy on host networking + loopback-published app
This doc captures why the production deployment puts Caddy in the host network namespace and reaches the app over loopback, and exactly what that requires in the Quadlet units and the Caddyfile.
Note: this file lives under
docs/at the repo root. It is not part of the Astro site (src/pages/+ theblogcontent collection are what get built and served). Do not move it undersrc/.
Why
With Podman's default port-publish path (PublishPort=80:80 on a
user-defined bridge), inbound packets are NAT'd by netavark before they
hit the container. Caddy then sees the bridge gateway as the source
address instead of the real client. That breaks two things we care about:
- fail2ban / nftables bans — the
nftables-multiportaction would populate the f2b set with the gateway's address, banning nothing useful. - Coraza's audit log — every "blocked" entry would record the same internal address, so manual triage is impossible.
Switching Caddy to the host network namespace removes that NAT hop: Caddy binds 80/443 directly on the host's interfaces and sees the real client IP for both v4 and v6.
What changed
1. quadlet/dev-blog-caddy.container
Caddy now lives in the host network namespace.
-Network=dev-blog.network
-NetworkAlias=caddy
-
-PublishPort=80:80
-PublishPort=443:443
-PublishPort=443:443/udp
-PublishPort=[::]:80:80
-PublishPort=[::]:443:443
-PublishPort=[::]:443:443/udp
+Network=host
Network=host is mutually exclusive with both Network=dev-blog.network
and any PublishPort= directives — Caddy binds 80/443 directly on the
host's interfaces, so the user-defined network and port-forwarding are
gone for this container. CAP_NET_BIND_SERVICE is still kept because
the container's non-root user still has to bind privileged ports.
2. quadlet/dev-blog-app.container
App stays on the user-defined network and publishes on host loopback only.
-# Not exposed publicly – Caddy reverse-proxies in over the internal network.
-# PublishPort=4321:4321
+# Caddy now runs on the host network namespace, so it cannot reach the app
+# via podman DNS (`app:4321`). Publish the app port on the host's *loopback*
+# only -- it is reachable to Caddy on the host but not from outside.
+PublishPort=127.0.0.1:4321:4321
+PublishPort=[::1]:4321:4321
The app keeps Network=dev-blog.network (so it could still talk to
future sidecars on that bridge), but now also surfaces on
127.0.0.1:4321 and [::1]:4321. nftables already accepts
iif "lo" accept, so loopback traffic is unfiltered.
3. caddy/Caddyfile
Reverse-proxy target updated.
-reverse_proxy app:4321 {
+reverse_proxy 127.0.0.1:4321 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
{remote_host} is now the real client IP (v4 or v6) because Caddy
sees the connection unmodified, instead of the bridge gateway. That
value flows into the JSON access log ("remote_ip") and Coraza's serial
audit log, which is exactly what the fail2ban filter keys on — so the
nftables-multiport action populates the right addresses in
f2b-table and bans actually work.
Topology, before vs. after
Before
Internet ─┐
(host nft) ─► podman PublishPort NAT ─► dev-blog (bridge, dual-stack)
├─ caddy (real IP lost)
└─ app (Caddy → app:4321 via podman DNS)
After
Internet ─┐
(host nft) ─► caddy in host netns (real client v4/v6 preserved)
│ reverse_proxy
▼
127.0.0.1:4321 / [::1]:4321 (loopback publish)
│
dev-blog (bridge, dual-stack)
└─ app
Things that did not need to change
dev-blog.network— still a dual-stack bridge; the app still uses it (and any future sidecar can join it).nftables.conf.j2— already acceptedtcp dport { 22, 80, 443 }andudp dport { 443 }in theinet filtertable, plusiif "lo" accept, so both Caddy's public listeners and the loopback hop to the app are covered.- fail2ban filter regexes, Coraza config, Ansible roles, Quadlet
[Install]lines.
Operational heads-up
compose.yaml(local-dev convenience) was deliberately left alone — it still wires Caddy through the bridge withPublishPort, which is fine for local iteration where preserving real client IPs doesn't matter.- If you ever add another container that Caddy needs to reach, follow
the same pattern: have that container publish to
127.0.0.1:<port>(and[::1]:<port>for v6) and reference it from the Caddyfile via loopback. - Because the app's loopback publish is bound to
127.0.0.1/[::1]only, it is not reachable from any other host on the network even if nftables were ever flushed — the kernel itself drops non-loopback traffic destined to those addresses.