Xcaddy + Cloudflare Tunnel + AdGuard Home: A Simpler Homelab Edge
I run a lot of services at home. Over time, my ingress and DNS setup became harder to reason about than the apps themselves. Traefik labels multiplied across compose files, per-service routing rules became noisy, and small changes required jumping between containers and dynamic files.
I wanted one place to read, one place to debug, and one policy for local and remote access. The stack I ended up with is native Caddy (built via Xcaddy) + Cloudflare Tunnel + AdGuard Home. This post is the architecture, why I moved, and exactly what changed.
Why I Moved Away from Traefik Labels and Pi-hole
Traefik worked, but label-driven routing became expensive to maintain at scale. A single service often required router, middleware, entrypoint, TLS, and service-port labels. Repeating that pattern across many stacks increased cognitive load and made reviews harder.
Caddy gave me a single-file model in /etc/caddy/Caddyfile. Each hostname is a readable block, and per-service tweaks live beside the route they affect. That reduced context switching immediately.
I also moved away from Pi-hole to AdGuard Home. The AdGuard UI is cleaner for daily operations, and its built-in filter ecosystem and rewrite workflow are smoother for my use case.
Architecture Overview
Core Components
- Xcaddy-built Caddy as reverse proxy and TLS control plane
- Cloudflare Tunnel for explicit remote exposure without inbound port forwarding
- AdGuard Home for LAN DNS, rewrites, filtering, and policy controls
Local Flow
- Client resolves service hostname using AdGuard Home
- AdGuard rewrite returns LAN endpoint
- Caddy handles HTTPS and proxies to the target service
Remote Flow
- Client reaches Cloudflare edge
- Cloudflare Tunnel forwards approved hostnames only
- Caddy routes request internally to upstream service
| Path | Entry Point | Name Resolution | Exposure Model |
|---|---|---|---|
| Local | Caddy on LAN | AdGuard rewrites | Private by default |
| Remote | Cloudflare edge + Tunnel | Cloudflare DNS/hostname policy | Explicit opt-in per hostname |
Caddy in One File: What Simplified Operations
My current Caddy config starts with Cloudflare ACME DNS integration and then declares host routes in one place:
{
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
home.semesmieh.com {
reverse_proxy 192.168.20.69:3000
}
code.semesmieh.com {
reverse_proxy http://192.168.20.69:8443
}
For edge cases, behavior stays close to the route. Example: self-signed upstreams use local transport overrides where needed:
portainer.semesmieh.com {
reverse_proxy https://192.168.20.69:9443 {
transport http {
tls_insecure_skip_verify
}
}
}
That single-file readability is the main reason this became easier to operate than label-heavy dynamic wiring.
AdGuard Home: Cleaner DNS and Filter Management
AdGuard now handles DNS and filtering directly from the host config at /opt/AdGuardHome/AdGuardHome.yaml.
- DNS service:
dns.port: 53 - Admin UI:
http.address: 0.0.0.0:8888 - Upstream DNS: Quad9 DoH endpoints
- Rewrites: service hostnames mapped to LAN targets under
filtering.rewrites
Example rewrite set from the running config:
filtering:
rewrites:
- domain: home.semesmieh.com
answer: 192.168.20.69
enabled: true
- domain: code.semesmieh.com
answer: 192.168.20.69
enabled: true
- domain: nvr.semesmieh.com
answer: 192.168.20.69
enabled: true
The built-in filter integrations were another practical win. Instead of maintaining fragmented external list workflows, I can manage a large set of curated feeds directly in AdGuard with better day-to-day visibility.
Why I Run Caddy and AdGuard Natively (Not Docker)
I deliberately run both as host-native services. In my environment, this improves responsiveness and reduces runtime overhead around critical network paths.
- Fewer container dependencies in the ingress and DNS control path
- More direct service management for two foundational components
- Less indirection during incidents and faster troubleshooting loops
Containers still make sense for many app workloads. For edge and DNS, native services gave me a cleaner operational model.
What I Lost and What I Gained
| Area | Lost | Gained |
|---|---|---|
| Ingress management | Label-driven service discovery convenience | Single-file config clarity and lower cognitive overhead |
| DNS operations | Pi-hole familiarity and legacy setup | Cleaner UI, richer integrated filter workflow, simpler rewrites |
| Runtime model | Uniform container pattern for everything | Native performance and simpler control for edge services |
Official Documentation
- Xcaddy (official GitHub)
- Caddy Modules Documentation
- Caddy Cloudflare DNS Module
- Cloudflare Tunnel Documentation
- Cloudflare Tunnel Use Cases
- AdGuard Home Documentation
Closing Thought
This migration was less about chasing new tools and more about reducing operational drag. Caddy gave me a one-file ingress model, AdGuard gave me cleaner DNS operations, and native deployment kept critical paths fast and straightforward.
If your homelab is growing and your edge stack feels harder to reason about than your actual apps, this pattern is worth a look.