Self-Hosted Homelab: The Three States I Was Missing
I wanted a boring self-hosted setup:
- Docker Compose for apps
- Caddy as the reverse proxy
- Cloudflare for DNS
- wildcard TLS through DNS-01
- AdGuard Home for LAN DNS and ad blocking
- Tailscale for private remote access
- Watchtower for selected automatic updates
The basic architecture is:
Client | v DNS | v Caddy | | homelab Docker network vApplicationThe app pattern
A normal app joins the shared Docker network:
services: app: image: <image> container_name: <app> restart: unless-stopped expose: - "3000" networks: - homelab
networks: homelab: external: trueThen Caddy:
app.<domain> { reverse_proxy app:3000}No need to publish every application port on the host.
The important lesson
The thing I kept confusing was configuration state vs container state vs application state.
There are really three questions:
What is on disk? ↓What does the running container have? ↓What is the process actually using?For Caddy:
cat Caddyfile
docker exec caddy cat /etc/caddy/Caddyfile
docker exec caddy caddy adapt --config /etc/caddy/Caddyfile --prettyA changed file on the host doesn’t mean the running process has loaded it.
For a Caddyfile change:
docker exec caddy caddy reload --config /etc/caddy/CaddyfileFor environment/image/Compose changes, recreate the container instead.
Cloudflare + wildcard TLS
The wildcard certificate uses the Cloudflare DNS challenge:
*.<domain> { tls { dns cloudflare {$CLOUDFLARE_API_TOKEN} resolvers 1.1.1.1 }}The 1.1.1.1 resolver is for Caddy’s DNS-01 flow. It doesn’t mean AdGuard can’t remain the LAN resolver.
The _acme-challenge TXT record is temporary, so an NXDOMAIN after successful issuance is not surprising.
AdGuard Home
AdGuard has two separate jobs:
DNS:LAN client -> AdGuard :53
Web UI:Browser -> Caddy -> adguard:3000This was another useful distinction.
The DNS service needs a host port. The web UI can simply be on the shared Docker network and reverse proxied by Caddy.
A LAN DNS rewrite can make:
app.<domain> -> <homelab LAN IP>while keeping the normal hostname and HTTPS.
The blank UI incident
One of the more useful bugs was AdGuard’s UI.
At one point:
curl -I https://adguard.<domain>returned:
HTTP/2 200But the browser showed nothing.
The HAR showed the response body was empty:
content-length: 0So the investigation changed from “is HTTPS working?” to “what is actually being returned?”
The useful tests were:
curl -s https://adguard.<domain> | head -c 1000and from inside Caddy:
docker exec caddy sh -c 'curl -sS -D- "http://adguard:3000/" -o /tmp/bodywc -c /tmp/body'The lesson is simple:
HTTP 200 is not the same thing as a working application.
For browser apps, check:
DNS -> TLS -> status code -> response body -> JS/CSS assets -> browser UIWatchtower
I also wondered whether Watchtower needed to be on the application network.
No.
It uses the Docker socket:
Watchtower -> /var/run/docker.sock -> Dockerwhile Caddy uses:
Caddy -> homelab -> ApplicationDifferent relationship, different reason for networking.
What I would do next time
Adding an app is now:
1. Put it on homelab.2. Don't publish its web port unless necessary.3. Test it directly from Caddy.4. Add the Caddy route.5. Validate/adapt the config.6. Reload Caddy.7. Configure DNS if needed.8. Test HTTPS.9. Test the actual GET response.10. Open the browser.And debugging follows:
host config ↓container config ↓process ↓Docker network ↓DNS ↓TLS ↓Caddy ↓HTTP body ↓browserThat’s probably the main thing worth remembering from this setup.
The services themselves are straightforward. The useful part was learning to identify which state is actually wrong before trying to fix it.