Hello caddy fam,
I’ve been running a two-Caddy setup for a while now: one instance at home acting as the source of truth for about 50 local sites, and another on a VPS serving as a hardened public edge forwarding allowlisted hosts back over WireGuard (headscale/tailscale).
Throw in split-horizon DNS (two AdGuard Home instances) plus Cloudflare on top, and exposing a single new service meant manually syncing configuration across five different control planes. As you can guess, these systems never complained when they disagreed, leading to plenty of silent failures, and always at the worst possible moment.
To solve this orchestration headache, I built Crenel.
Now, running a command like:
crenel expose photos --to immich:2283 --auth authelia
…provisions the Caddy route, the DNS rewrites on both local resolvers, and the public Cloudflare record as a single atomic transaction. It then re-reads everything—including fetching the live Caddy config—to prove the changes actually landed. I quickly learned that a 200 OK from the admin API doesn’t always guarantee traffic is flowing, so Crenel treats that response as a hint, not proof.
Here are a few API implementation details and war stories that might be interesting to this crowd:
- Granular
/config/...PATCHes vs. Full Loads: Crenel targets specific config paths viaPATCHrequests rather than dumping everything into a fullPOST /load. Along the way, I learned the hard way that a full load can silently drop a non-defaultadminblock if your generation logic isn’t perfect—effectively sawing off the branch you’re sitting on. Granular applies completely avoid this risk, though adding a strict safeguard for full-load scenarios is still on my roadmap. - Caddyfile Reconciliation & Container Reload Gotchas: Since admin API writes are in-memory, Crenel maintains durability by slicing its managed routes into your on-disk
Caddyfilebetween specific markers. It runs acaddy validateon the candidate file, proves it adapts cleanly to the live config, and triggers the reload. However, makingcaddy reloadrobust across environments bit me twice in production:caddy reloaddefaults tolocalhost:2019, which can stubbornly resolve to::1even if your admin API is explicitly bound to an IPv4 address.- If Caddy is running inside a container, executing the reload command from the host obviously goes nowhere. It has to execute inside the namespace.
To fix this once and for all, Crenel now strictly pins the--addressflag and pipes all validation and reload actions through the exact same transport layer used for the API calls.
- Deterministic
forward_authGeneration: For authentication, Crenel skips the guesswork and directly renders the canonical JSON expansion. Which is the exact shape the Caddyfile directive compiles down to, based on an operator-declared reference. To prevent broken configs, the planning stage explicitly rejects the transaction at compile time if you define an authorizer but omit the verification URI.
The project is open source and I’d love to get your thoughts on how it interacts with Caddy’s API architecture, or hear how others are handling config sync across split-DNS environments! Help me try to break it and make it better!
- GitHub: crenelhq/crenel
- Website: crenel.sh
AI Disclosure: I am not a traditional programmer by trade, but have spent many years in networking. I designed the architectural requirements and logic for Crenel, but used Claude (Opus 4.8) and Fable to generate the actual codebase. It is written completely in native Go with zero external dependencies to keep it lightweight and fast. Everything has been thoroughly tested, verified, and debugged directly against my own live production infrastructure to ensure it behaves exactly as described.