one door to the house: caddy, docker, and the reverse proxy

July 1, 2026 meta devlogdocker

six containers, one door

The whole stack is six services in docker compose: postgres, minio, auth, blog, frontend, and caddy sitting in front of all of it. Only one of those, caddy, actually has a port exposed to the outside world. Everything else talks to everything else over the compose network, container name to container name, completely invisible from outside the box.

That's the entire idea of a reverse proxy, once you strip the buzzwords off it: one door into the house, and whichever room you actually wanted, the door figures that out for you based on the path you asked for. The browser only ever knows about one address. It has no idea auth and blog are even separate processes running separately.

handle /media/* {
    request_body {
        max_size 2MB
    }
    reverse_proxy minio:9000
}

handle {
    request_body {
        max_size 2MB
    }
    reverse_proxy frontend:3000
}

Anything not explicitly matched above falls to that last bare handle block at the bottom, which goes to the frontend. The frontend then does its own internal proxying to auth and blog for the actual API calls, so from Caddy's point of view there's really only two destinations that matter directly: minio for raw media files, and frontend for absolutely everything else.

auto-TLS, the thing I haven't actually needed yet

Caddy's whole reputation is automatic HTTPS, it'll go get you a real certificate from Let's Encrypt just from naming a real domain in the config. Locally none of that applies, {$DOMAIN} defaults to localhost and Caddy just serves plain HTTP, no certificate dance needed at all. The moment this actually goes on a real domain, that same config line is supposed to just start requesting and renewing certificates on its own, no separate certbot cron job, no manual renewal ever. Haven't tested that leg of it for real since this blog isn't live on a real domain yet, which is honestly the part I'm least sure will go smoothly on the first try. Everything always looks simple in the docs until you're the one actually running it.

one hop, trusted, and nothing further back than that

Sitting behind a proxy breaks two things you don't think about until they break: the frontend's own CSRF check, which compares the request's origin against what it thinks its own address is, and rate limiting, which needs the real visitor's IP, not Caddy's.

# Behind Caddy the browser's origin is the public DOMAIN, not :3000.
# Derive the origin from Caddy's forwarded headers so adapter-node's
# CSRF/origin check on form POSTs passes for both http://localhost and
# https://<domain>.
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: x-forwarded-for
XFF_DEPTH: "1"

That last line, XFF_DEPTH: "1", took the longest to actually understand. Without it, getClientAddress() just returns whatever container happened to open the socket, which is always Caddy itself, since Caddy is the only thing that ever connects to the frontend container directly. Telling it to trust exactly one hop means it reads the client address from the header Caddy itself appended, not from anything a client could have forged further up the chain by prepending fake entries of their own. Get that number wrong in either direction, trust zero hops and every visitor looks like Caddy, trust too many and a malicious client can just lie about who they are.

healthchecks: making "up" mean something

Every service in compose has a healthcheck, and I didn't take these seriously at first, just copied one from an example and moved on:

# what I copied from an example, didn't think about it further
frontend:
  healthcheck:
    test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

Then I actually needed depends_on: condition: service_healthy to work correctly, since blog shouldn't even start accepting traffic until postgres is actually ready to answer queries, not just "the container process started."

frontend:
  healthcheck:
    # Use 127.0.0.1, not localhost: in Alpine localhost resolves to IPv6 ::1
    # while the Node server listens on IPv4, so a localhost probe fails and
    # the container is wrongly marked unhealthy.
    test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/"]
    interval: 5s
    timeout: 5s
    retries: 5

That 127.0.0.1 instead of localhost cost me a genuinely confused half hour. On the Alpine-based images everything here runs on, localhost resolves to the IPv6 loopback first, but the Node server only listens on the IPv4 socket, so the healthcheck connected to nothing, timed out, and marked a perfectly working container unhealthy. Compose then refused to bring up anything depending on it, so the whole stack just sat there, everything actually fine underneath, docker convinced otherwise. Swapping in the literal IP fixed it outright, and now I default to 127.0.0.1 in every healthcheck out of habit rather than trust localhost to mean what I think it means inside a container.

Caddy's own healthcheck hits its internal admin API instead of the public site for the same kind of reason, so it keeps working once DOMAIN becomes a real hostname and a plain http://localhost request would stop matching anything at all.

Same reasoning runs through postgres, minio, auth, and blog too, each one testing something that actually proves readiness (pg_isready, MinIO's own health endpoint, each Go service's /healthz) rather than just checking the process hasn't crashed outright. A crashed process and a process that's up but still running its own startup migrations look identical from the outside if all you check is "did it accept a TCP connection," and I only cared about that distinction once compose actually raced auth's migrations against blog trying to talk to a database that wasn't ready for it yet.

the path-matching gotcha

One thing about Caddy that isn't obvious from a skim of the docs: handle blocks aren't matched in the order you wrote them in the file, they're matched by how specific the path pattern is. There's a block for /auth/permissions that returns a bare 404 (that endpoint serves an internal role-to-capability matrix that has no business being reachable from a public GET), sitting well above the catch-all handle {} at the very bottom of the file. Doesn't matter that it's not last, the more specific path always wins regardless of where it sits. Took actually reading Caddy's documentation instead of guessing to trust that, since it's exactly the kind of assumption ("surely first match wins, like everything else I've used") that would have quietly broken something eventually, probably at the worst time.

reloading Caddy's config without dropping a connection

0 comments

Log in to comment.

Log in

No account?