Skip to main content

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:

FQDNBackend
pihole-mistborn.home.arpahttp://192.168.95.3:80
pihole-fablehaven.home.arpahttp://127.0.0.1:80
pihole-hogwarts.home.arpahttp://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.
  • tls directive uses the Step-CA cert issued to fablehaven — a single cert covers all three Pi-hole virtual hosts via SNI
  • header_up Host pi.hole — Pi-hole v6 requires the Host header to be pi.hole or 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:

  1. Directory must exist before unarchive — create it unconditionally
  2. 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

  • 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)