9 min read
Reverse proxy in the homelab: Caddy, TLS, and when Pi-hole gets in the way

In the sslip.io post the naming layer was solved in minimal form: hostnames that point at an IP without buying a domain or running BIND. But a name doesn’t help much if every service still listens on its own port (:3000, :8123, :9000) and the browser still screams about self-signed certificates.

The next layer is TLS termination and HTTP routing: a reverse proxy that listens on 443, presents a valid certificate, and distributes traffic by hostname. Caddy, nginx, and Traefik all do that; this post focuses on Caddy because it fits the personal homelab profile (automatic TLS, readable config, modest footprint) and because that’s where the real friction shows up when you mix it with a local resolver like Pi-hole.

The thread is the frame: what problem the proxy solves, what breaks when you add internal DNS, and how it fits the RPS principle.

The stack so far

Remember the order we’ve been building:

  1. Network access: Tailscale, WireGuard, or OpenVPN
  2. Name resolution: sslip.io as a bridge, MagicDNS, or Pi-hole
  3. TLS termination and HTTP routing ← here
  4. Application identity: OIDC, forward auth; Zitadel and the RPS spectrum

The proxy doesn’t replace VPN or DNS. It assumes you already know how to reach the network and resolve the name. Its job is making https://grafana.192-168-1-47.sslip.io hit the right container with a certificate the browser accepts.

What a reverse proxy does (and doesn’t)

A reverse proxy listens on standard ports (80 and 443) and forwards traffic to internal services by rules: hostname, path, headers. In the homelab that means:

Without proxyWith proxy
192.168.1.47:3000https://grafana.lan
Certificate per service (or none)One certificate at the edge
Remember portsRemember names

What it doesn’t do: authenticate application users (that’s layer 4) or resolve DNS names (that’s layer 2). Confusing layers leads to mounting OAuth in Caddy when you only needed routing, or exposing services without a perimeter when you still don’t have VPN.

Caddy in the RPS framework

SolutionRPSWhat you payWhat you gain
Direct port accessMinimal stoneExposed surface, no unified TLSZero proxy
CaddyOperational stoneLearning its DSL; coupling to the Caddy ecosystemAutomatic TLS, declarative config, low maintenance
nginxStone / paperMore manual config; Certbot on the sideMaximum control, universal reference
TraefikOperational stoneDocker labels, routers mental modelAuto-discovery in containerized stacks
Cloudflare TunnelOperational stoneAnchor in CloudflareNo open ports; TLS managed outside

Caddy is operational stone in the good sense: TLS + routing with little ceremony. The cost is a soft anchor in the ecosystem (migrating to nginx isn’t impossible, but you rewrite configs) and the temptation to use Caddy-specific features (forward_auth, modules) without evaluating reversibility.

For a one-person homelab that already chose operational simplicity at the VPN layer, Caddy is the honest continuation.

TLS: HTTP-01, sslip.io, and the private LAN

In the previous post we saw that Let’s Encrypt with HTTP-01 works with 203-0-113-7.sslip.io if the public IP is reachable on port 80. Caddy automates it: you declare the hostname, Caddy obtains and renews the certificate.

With private IPs (192.168.x.x) things change. Let’s Encrypt can’t reach your LAN from the internet. Real options:

  • Self-signed certificate: fast, the browser complains.
  • Internal CA: more work, viable on a controlled network.
  • DNS-01 with wildcard: scissors; sslip.io documents it, not the default path.
  • Being inside the VPN/tailnet with TLS managed by Tailscale, already covered in the access post.

Caddy automates the dance when conditions are met; network physics is still there.

Subdomains with sslip.io

A common pattern: grafana.192-168-1-47.sslip.io points at the same IP as 192-168-1-47.sslip.io; Caddy routes by hostname to the right backend. That gives you several services behind one proxy without Pi-hole or your own domain, as long as the IP doesn’t change and you accept the name’s aesthetics.

Minimal Caddyfile example

One block per hostname shows the pattern: automatic TLS, backend on the LAN.

{
	# Optional: email for Let's Encrypt notices
	email admin@example.com
}

grafana.192-168-1-47.sslip.io {
	reverse_proxy 192.168.1.47:3000
}

homeassistant.192-168-1-47.sslip.io {
	reverse_proxy 192.168.1.48:8123
}

Caddy listens on 80 and 443, obtains certificates for each hostname, and forwards to the internal port. If the proxy host is 192.168.1.47 and backends live on other machines, change the reverse_proxy IPs: the sslip.io hostname still points at the proxy IP, not the backend’s.

With a private IP and no public reachability on 80, add tls internal inside the block to force Caddy’s self-signed certificate (fine on the LAN, useless for visitors from the internet).

Pi-hole enters the picture

sslip.io resolves names without your own infrastructure. Pi-hole (with Unbound behind it) is the jump to sovereign DNS on the LAN: arbitrary records (grafana.homelab, nas.lan), ad blocking, full control of the resolver.

The problem: mixing sslip.io with Pi-hole without a plan is false stone. Two resolvers, contradictory rules, and headaches that only show up when you deploy the proxy.

Split DNS

Split DNS means the same DNS question gets different answers depending on who asks:

QueryFrom the internetFrom the LAN (Pi-hole)
app.mydomain.comRouter’s public IPProxy’s internal IP (192.168.1.47)

Without split DNS, a device at home may resolve your domain to the public IP, leave via the router, attempt hairpin NAT, and fail, or take a slow, unpredictable path. With split DNS, the LAN resolves straight to the internal IP.

Caddy needs client and proxy to agree on which hostname each service uses. If Pi-hole returns one thing and the public resolver another, the proxy issues certificates for a name that doesn’t resolve the same way from inside.

Rebind protection

Pi-hole includes protection against DNS rebinding: it blocks answers that point to private IPs for public domains. It’s a legitimate defense.

But sslip.io returns private IPs by design (192-168-1-47.sslip.io192.168.1.47). Pi-hole may block those answers. Symptom: the name resolves outside home and fails inside, or the reverse.

Fix: whitelist trusted domains (sslip.io, nip.io) or disable rebind protection for those cases, conscious of the security trade-off.

ACME renewals with Pi-hole

When Caddy renews a certificate with HTTP-01, Let’s Encrypt queries public DNS and connects to your public IP on port 80. If Pi-hole intercepts all DNS queries (including from the proxy host itself) and returns the internal IP for your domain, renewal can fail: LE tries to reach 192.168.x.x from the internet.

Patterns that work:

  • Local DNS records in Pi-hole for the public domain pointing at the internal IP for LAN clients only, plus a firewall rule letting the proxy answer on 80 from outside.
  • Conditional DNS: the proxy uses a different upstream resolver than clients for ACME queries.
  • DNS-01 instead of HTTP-01 (more complex, avoids the hairpin problem).

There’s no single recipe; it depends on whether you have a public IP, CG-NAT, or tailnet-only access. Before blaming Caddy, diagnose with dig (@Pi-hole vs @8.8.8.8).

Boot order and dependencies

Another silent friction: Caddy starts and wants to renew certificates; Pi-hole isn’t ready yet; the proxy resolves wrong and enters a retry loop. Or the reverse: Pi-hole depends on a container Caddy hasn’t routed yet.

Practical rules:

  • Pi-hole and the proxy on stable hosts (fixed IP or DHCP reservation).
  • depends_on in Docker doesn’t guarantee the service is ready; use healthchecks if the stack is fragile.
  • Document which service must start first after a long power outage, don’t rely on memory.

RPS framework: bridges and anchors

DecisionTypeWhy
Caddy with versioned CaddyfileBridgeMigrating to nginx is work, not redoing topology
Let’s Encrypt certificates in CaddyBridgeStandard; another ACME client can replace it
Pi-hole as sole LAN resolverStone / anchorControl and ops friction in exchange for DNS sovereignty
Mixing sslip.io + Pi-hole + real domain without a mapFalse stoneThree sources of truth, debugging hell
Cloudflare Tunnel as sole edgeAnchorTLS and routing delegated; hard to return to pure self-hosted

Connects to digital sovereignty: the proxy may live on your mini PC and certificates on Let’s Encrypt, but if Pi-hole filters a renewal wrong, you lose TLS and the whole trust chain suffers.

When to use what

Caddy + sslip.io is enough when:

  • You’re testing the stack before buying a domain.
  • You have a public IP and few services.
  • You mostly access over the tailnet with Tailscale TLS for critical stuff.

Add Pi-hole (or Unbound) when:

  • You need dozens of arbitrary local names (*.homelab).
  • You want ad blocking across the LAN.
  • You have your own domain and need serious split DNS.

Move to own domain + Caddy when:

  • The service is public or semi-professional.
  • sslip.io already feels fragile or ugly to share.
  • You need name stability independent of IP.

Honest limitations

  • Caddy doesn’t fix CG-NAT. Without a reachable public IP, HTTP-01 from the internet won’t work; you need tailnet, tunnel, or DNS-01.
  • A misconfigured proxy is worse than none. Centralized attack surface; one Caddyfile mistake exposes everything.
  • Pi-hole + proxy + sslip.io + real domain can coexist, but needs an explicit map of which resolver answers what for whom.
  • Let’s Encrypt has rate limits. Don’t loop renewals against production; use staging or wait (five failed renewals per week and you start having problems).

What comes next

With name, certificate, and HTTP routing settled, the next question is who can log in: Grafana, your PWA, the admin panel. That’s the identity layer (OIDC, forward auth, Zitadel vs Authelia vs “basic auth and done”) in post 009.

The proxy terminates TLS; DNS names things; VPN gets you on the network. Without a map of which resolver answers what, the three layers step on each other. Each in its place, conscious anchors.

Comments