Crenel: A CLI that drives multi-edge Caddy admin APIs with atomic DNS + read-back verification (war stories included)

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 via PATCH requests rather than dumping everything into a full POST /load. Along the way, I learned the hard way that a full load can silently drop a non-default admin block 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 Caddyfile between specific markers. It runs a caddy validate on the candidate file, proves it adapts cleanly to the live config, and triggers the reload. However, making caddy reload robust across environments bit me twice in production:
    1. caddy reload defaults to localhost:2019, which can stubbornly resolve to ::1 even if your admin API is explicitly bound to an IPv4 address.
    2. 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 --address flag and pipes all validation and reload actions through the exact same transport layer used for the API calls.
  • Deterministic forward_auth Generation: 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!


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.