Caddy Reverse Proxy Deployment — Pi-hole TLS Termination on fablehaven
Summary
Pi-hole v6 exposes its admin UI on port 80 (HTTP) by default, with the REST API on port 8080 via FTL directly. Neither is encrypted. For a home lab with an internal CA already running, serving the admin UI over HTTPS via a reverse proxy is straightforward — but the details have friction.
This lab note documents the Caddy reverse proxy deployment on fablehaven, the Caddyfile design for Pi-hole TLS termination, the gotchas encountered during implementation, and the Ansible role that manages it.
Architecture
Browser / Ansible
└── https://pihole-mistborn.home.arpa (port 443)
└── Caddy on fablehaven (192.168.95.6)
├── TLS: Step-CA cert for fablehaven.home.arpa
└── Proxy: http://192.168.95.3:80
└── Pi-hole FTL on mistborn (port 80)
Three virtual hosts, one Caddy instance:
| FQDN | Backend |
|---|---|
pihole-mistborn.home.arpa | http://192.168.95.3:80 |
pihole-fablehaven.home.arpa | http://127.0.0.1:80 |
pihole-hogwarts.home.arpa | http://192.168.95.158:80 |
Pi-hole local DNS records for all three FQDNs point to
192.168.95.6 (fablehaven). Caddy terminates TLS and proxies to
the respective Pi-hole instance over plain HTTP on the LAN.
Notes
Ansible role design
roles/caddy/tasks/
├── 00_guard.yml
├── src/
│ ├── 05_preflight.yml # assert version, checksum vars defined
│ ├── 10_install.yml # download binary, verify SHA256, install
│ ├── 20_user.yml # create caddy system user
│ ├── 30_dirs.yml # /etc/caddy, /var/lib/caddy, /tmp/caddy_extract
│ ├── 40_config.yml # template Caddyfile.j2, validate config
│ └── 50_service.yml # systemd unit, enable, start/reload
Binary downloaded from GitHub releases, SHA256 verified against
fleet_versions.yml. Architecture map handles arm64 (Pi) vs amd64
automatically — same pattern as step-ca and step CLI.
Caddyfile design
The Caddyfile uses a global options block and one site block per Pi-hole instance:
{
http_port 8080
https_port 443
}
pihole-mistborn.home.arpa {
tls /etc/step/certs/host.crt /etc/step/certs/host.key
reverse_proxy http://192.168.95.3:80 {
header_up Host pi.hole
}
@http {
protocol http
}
redir @http https://{host}{uri} permanent
}
Key elements:
http_port 8080— Caddy's default HTTP port shifted to 8080 to avoid conflict with Pi-hole FTL which also binds port 80 on fablehaven. Caddy must not own port 80 on the fablehaven host.tlsdirective uses the Step-CA cert issued to fablehaven — a single cert covers all three Pi-hole virtual hosts via SNIheader_up Host pi.hole— Pi-hole v6 requires theHostheader to bepi.holeor it returns a 404. Without this rewrite, every proxied request fails.- HTTP redirect matcher — redirects plain HTTP requests to HTTPS
Pi-hole port 80 conflict on fablehaven
fablehaven runs both Pi-hole and Caddy. Pi-hole FTL binds port 80 on the host. Caddy also wants port 80 for HTTP redirect handling. These conflict.
Resolution: set http_port 8080 in the Caddyfile global block. Caddy
listens on 8080 for HTTP and 443 for HTTPS. HTTP redirects use the
correct port in the redirect URL:
redir @http https://{host}{uri} permanent
Do not use admin off in the global block — this disables the Caddy
admin API which is required for caddy reload to work without a full
service restart.
Caddyfile validation in check mode
The Ansible role validates the Caddyfile before starting the service:
- name: Validate Caddyfile
ansible.builtin.command:
argv:
- /usr/local/bin/caddy
- validate
- --config
- /etc/caddy/Caddyfile
- --adapter
- caddyfile
The --adapter caddyfile flag is required — without it, Caddy assumes
JSON config format and rejects the Caddyfile syntax.
In check mode, this task fails because the Caddyfile has not been
written yet (the template task is skipped). This is handled with
when: not ansible_check_mode on the validate task.
A second check-mode issue: the /tmp/caddy_extract directory used by
the unarchive module must exist before extraction. In check mode,
the directory creation task is skipped, causing the unarchive task to
fail. Fix: create the directory unconditionally before the unarchive
task, not as a notified handler.
Jinja2 delimiter escaping in Caddyfile.j2
Caddy uses { and } extensively in its config syntax. Jinja2 also
uses { and } for template expressions. This creates conflicts in
.j2 templates.
The solution is to use Jinja2's raw block for Caddyfile sections that contain literal braces that should not be interpreted as template expressions:
{% raw %}
pihole-{{ item.name }}.home.arpa {
reverse_proxy http://{{ item.ip }}:80 {
header_up Host pi.hole
}
}
{% endraw %}
Note: the {% raw %} block suppresses ALL Jinja2 processing — variable
references inside raw blocks are not rendered. Structure the template
to interpolate variables outside raw blocks and use raw blocks only
for literal Caddy syntax.
Backslash escaping (\{) does not work in Caddyfile.j2 — remove any
backslashes added to escape braces. Caddy treats them as literal
backslashes, not escapes.
Host header rewrite is mandatory for Pi-hole v6
Pi-hole v6 validates the Host header on incoming requests. If the
header does not match pi.hole (or the configured pihole_domain),
Pi-hole returns a 404. A reverse proxy that forwards the original
Host header (pihole-mistborn.home.arpa) will never succeed.
reverse_proxy http://192.168.95.3:80 {
header_up Host pi.hole
}
This must appear inside the reverse_proxy directive, not as a
standalone header directive. The header_up modifier rewrites the
upstream request header before it reaches Pi-hole.
Internal DNS records
Three Pi-hole local DNS records added to point the FQDNs at fablehaven:
pihole-mistborn.home.arpa → 192.168.95.6
pihole-fablehaven.home.arpa → 192.168.95.6
pihole-hogwarts.home.arpa → 192.168.95.6
All three resolve to fablehaven because Caddy runs there. The records
are managed via the pihole_dns Ansible role which pushes custom DNS
entries to the Pi-hole cluster via the v6 REST API.
Insights
Caddy is the right tool for this
Caddy's automatic HTTPS handling, clean Caddyfile syntax, and zero-dependency binary make it ideal for a home lab reverse proxy. The only operational complexity is the Jinja2 delimiter conflict — once that pattern is established, the Caddyfile template is clean and maintainable.
Port conflicts require explicit global config
Caddy's default assumption is that it owns ports 80 and 443. On a host
that runs other services on those ports, this must be overridden
explicitly in the global block. http_port and https_port are the
levers. Missing this causes silent failures where Caddy starts but
cannot bind.
admin off is a trap
Disabling the Caddy admin API prevents caddy reload from working.
Reloading config without restarting the service requires the admin API.
Leave it enabled (the default) and restrict it to localhost if needed:
{
admin localhost:2019
}
Check mode requires defensive task ordering
Two check-mode failures were encountered:
- Directory must exist before
unarchive— create it unconditionally - Caddyfile validation requires the file to exist — skip in check mode
Both are patterns worth applying to any role that downloads and extracts binaries.
Results
- Caddy running on fablehaven, serving three Pi-hole virtual hosts
- All three admin UIs accessible over HTTPS with valid Step-CA certs
- HTTP to HTTPS redirect working for all three hosts
- Pi-hole REST API accessible on port 8080 directly (not proxied — Caddy intercepts port 80, not 8080)
- Ansible role idempotent: re-running does not restart Caddy unless Caddyfile or binary changes
Related
- newcomb-labs/engineering-journal-kb #432 — ADR: Caddy vs Traefik vs NPM
- newcomb-labs/engineering-journal-kb #457 — lab note: Step-CA PKI deployment
- newcomb-labs/engineering-journal-kb #459 — lab note: IoT VLAN 94
- mistborn-ops/ansible #484–#503 (caddy role PRs)
- newcomb-labs/engineering-journal-kb #458 (this issue)