Self-hosted services for a Raspberry Pi 5 (aarch64) home lab.
Configuration and third-party assets are versioned here; management is done with
small Lua scripts (system lua 5.1) that drive Docker Compose.
Each service is a compose stack in its own directory. Stacks that sit behind
the reverse proxy share an external docker network, homelab_edge. The Lua
scripts operate on all stacks by default, or one if you name it
(lua scripts/deploy.lua caddy).
Internet ─ router ─ LAN ──▶ Caddy :80/:443 (HTTPS) ──▶ service containers
└────▶ Pi-hole :53 (DNS) ──▶ Unbound ──▶ root servers
Two containers in one compose stack under pihole/:
- Pi-hole v6 — the LAN-facing resolver; filters ads/trackers.
- Unbound — recursive resolver behind Pi-hole. Pi-hole forwards to Unbound
(
172.28.0.53#53on a private docker network), and Unbound resolves straight from the root servers with DNSSEC validation. No dependence on Cloudflare/Google, and DNS queries don't leak to a third-party resolver.
LAN clients → Pi-hole (filter) → Unbound (recurse + DNSSEC) → root/authoritative
pihole/
├── docker-compose.yml # declarative stack: pihole + unbound + private net
├── .env.example # config template (copy -> .env, git-ignored)
├── 3rd_party/
│ └── adlists.txt # third-party blocklists (source of truth)
├── unbound/
│ └── custom.conf.d/
│ └── 00-homelab.conf # Unbound overrides (privacy/hardening), versioned
└── etc-pihole/ # Pi-hole persistent data (git-ignored, created on deploy)
Caddy owns host ports 80/443 and fronts every service with HTTPS. Service names
live under home.arpa (RFC 8375, reserved for home networks) and resolve
LAN-wide via Pi-hole's local DNS. TLS uses Caddy's internal CA — no public
domain required.
caddy/
├── docker-compose.yml # caddy on :80/:443, attached to homelab_edge
├── Caddyfile # per-site routing (versioned) — add services here
├── data/ # certs + internal CA (git-ignored, keep it!)
└── config/ # Caddy autosaved config (git-ignored)
pihole.home.arpa→ Pi-hole admin (proxied topihole:80).uptime.home.arpa→ Uptime Kuma (proxied touptime-kuma:3001).- To trust the HTTPS names without browser warnings, export and install Caddy's
root CA:
lua scripts/caddy-ca.lua(writescaddy/root-ca.crt+ install steps). - Add a service: give its container the
homelab_edgenetwork, then add a block tocaddy/Caddyfile(name.home.arpa { tls internal; reverse_proxy container:port }) and adnsmasq.drecord if it needs a new hostname.
Uptime Kuma watches the other services
(HTTP/ping/DNS checks, status pages, notifications to Telegram/etc.). It has no
host port of its own — Caddy fronts it at uptime.home.arpa.
uptime-kuma/
├── docker-compose.yml # uptime-kuma on homelab_edge (no host port; via Caddy)
└── data/ # SQLite DB: monitors, admin account, settings (git-ignored, keep it!)
On first visit to https://uptime.home.arpa you create the admin account in the
browser (there is no env-based bootstrap). Then add monitors — e.g. ping the Pi
or HTTP-check https://pihole.home.arpa.
Scripts in scripts/ — each takes an optional stack name
(pihole / caddy); with none they act on all stacks:
| Command | Does |
|---|---|
lua scripts/deploy.lua [stack] |
Create the edge network, start stacks, load adlists |
lua scripts/status.lua |
Show every stack + Pi-hole/Unbound/Caddy status |
lua scripts/update.lua [stack] |
Pull latest images and recreate containers |
lua scripts/down.lua [stack] |
Stop stacks (--purge also deletes Pi-hole data) |
lua scripts/sync-adlists.lua |
Re-apply 3rd_party/adlists.txt and rebuild gravity |
lua scripts/caddy-ca.lua |
Export Caddy's root CA for installing on devices |
lua scripts/deploy.luaPrints the generated Pi-hole admin password (also stored in pihole/.env).
Access:
- Pi-hole via Caddy:
https://pihole.home.arpa/admin - Pi-hole direct fallback:
http://<pi-ip>:8080/admin
Point your router's DNS at the Pi's IP so the whole network is filtered and
the *.home.arpa names resolve.
- Settings (upstream, password, ports, timezone): edit
pihole/.env, thenlua scripts/update.lua.PIHOLE_UPSTREAMSdefaults to Unbound (172.28.0.53#53); override it to bypass Unbound (e.g.1.1.1.1;1.0.0.1). - Blocklists: edit
pihole/3rd_party/adlists.txt, thenlua scripts/sync-adlists.lua. That file is the source of truth for the adlists taggedmanaged-by-homelab: lists you remove from it are removed from Pi-hole on the next sync. - Unbound behaviour: edit
pihole/unbound/custom.conf.d/00-homelab.conf, thenlua scripts/update.lua. The image ships a tuned base config (recursion, DNSSEC, caching, LAN access-control); this drop-in only layers overrides. - Local DNS names: edit
pihole/dnsmasq.d/02-homelab-local.conf(points*.home.arpaat the Pi), thenlua scripts/update.lua pihole. - Proxy routes: edit
caddy/Caddyfile, thenlua scripts/update.lua caddy(deploy validates the Caddyfile before touching the running proxy).
- Requires Docker + the Compose plugin; the invoking user must be in the
dockergroup. - Host ports in use: 53 (DNS), 80/443 (Caddy), 8080 (Pi-hole direct fallback).
- The
*.home.arparecord points at192.168.1.93. If the Pi's LAN IP changes, updatepihole/dnsmasq.d/02-homelab-local.conf— best set a DHCP reservation. - Verify DNSSEC any time:
sigok.verteiltesysteme.netmust resolve andsigfail.verteiltesysteme.netmust fail (SERVFAIL) — the latter proves Unbound is rejecting bad signatures.