---
title: "Reverse proxy in the homelab: Caddy, TLS, and when Pi-hole gets in the way"
description: "Caddy as a homelab reverse proxy: TLS, split DNS, rebind protection, and ACME renewals with Pi-hole, after VPN and sslip.io."
date: 2026-08-15
locale: en
url: https://escribano.dev/en/blog/008-caddy-reverse-proxy-pihole/
---
In the [sslip.io post](/blog/007-sslip-io-homelab-dns/) 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](/blog/002-rps-principle/).

## The stack so far

Remember the order we've been building:

1. **Network access**: [Tailscale, WireGuard, or OpenVPN](/blog/006-homelab-vpn-access/)
2. **Name resolution**: [sslip.io as a bridge](/blog/007-sslip-io-homelab-dns/), MagicDNS, or Pi-hole
3. **TLS termination and HTTP routing** ← here
4. **Application identity**: OIDC, forward auth; [Zitadel and the RPS spectrum](/blog/009-homelab-identity/)

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 proxy | With proxy |
|---------------|------------|
| `192.168.1.47:3000` | `https://grafana.lan` |
| Certificate per service (or none) | One certificate at the edge |
| Remember ports | Remember 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

| Solution | RPS | What you pay | What you gain |
|----------|-----|--------------|---------------|
| Direct port access | Minimal stone | Exposed surface, no unified TLS | Zero proxy |
| **Caddy** | Operational stone | Learning its DSL; coupling to the Caddy ecosystem | Automatic TLS, declarative config, low maintenance |
| **nginx** | Stone / paper | More manual config; Certbot on the side | Maximum control, universal reference |
| **Traefik** | Operational stone | Docker labels, routers mental model | Auto-discovery in containerized stacks |
| **Cloudflare Tunnel** | Operational stone | Anchor in Cloudflare | No 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](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) 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](/blog/006-homelab-vpn-access/).

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.

```caddyfile
{
	# 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:

| Query | From the internet | From the LAN (Pi-hole) |
|-------|-------------------|------------------------|
| `app.mydomain.com` | Router's public IP | Proxy'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](https://en.wikipedia.org/wiki/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.io` → `192.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

| Decision | Type | Why |
|----------|------|-----|
| Caddy with versioned `Caddyfile` | Bridge | Migrating to nginx is work, not redoing topology |
| Let's Encrypt certificates in Caddy | Bridge | Standard; another ACME client can replace it |
| Pi-hole as sole LAN resolver | Stone / anchor | Control and ops friction in exchange for DNS sovereignty |
| Mixing sslip.io + Pi-hole + real domain without a map | False stone | Three sources of truth, debugging hell |
| Cloudflare Tunnel as sole edge | Anchor | TLS and routing delegated; hard to return to pure self-hosted |

Connects to [digital sovereignty](/blog/005-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](/blog/009-homelab-identity/).

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.
