Certificate renewal is one of those problems that is completely solved right up until it isn’t. On Kubernetes you install cert-manager and never think about it again. On Azure Container Apps you tick the managed certificate box and — for the common case — you also never think about it again. This post is about the case in between, and about acme-az-aca, the small Go service I ended up writing for it.
The README covers how to deploy and configure it. I won’t repeat any of that here — this is the part a README can’t tell you: why the tool looks the way it does.
The gap between managed certs and cert-manager#
To be clear up front: if a single container app serves your custom domain directly, Azure’s managed certificates are the right answer. They’re free, automatic, and zero-maintenance. I’d recommend them first every time.
In this setup they weren’t an option. Traffic entered through an nginx reverse proxy running as its own container app — the standard workaround for Container Apps’ lack of cross-app path routing — and that proxy owned ingress for every custom domain. Managed certificates assume the opposite topology: DigiCert validates against the container app directly, DNS has to map straight to the app’s generated domain, and the documentation is explicit that an intermediate hop blocks issuance and renewal. The feature was also simply young at the time — it spent close to a year in preview and reached general availability only in March 2024. Whichever way you turned it, the free path didn’t fit this topology, and the remaining vendor answer was buying certificates per domain: a recurring cost for something Let’s Encrypt hands out for free.
What Container Apps did support cleanly was bring-your-own certificates consumed from Key Vault. That decided the shape of the solution: get Let’s Encrypt certificates into Key Vault automatically, let Container Apps pick them up for the domain bindings, and keep them fresh without a human in the loop.
On Kubernetes that piece is cert-manager. But Container Apps is not Kubernetes — that’s rather the point of the service — so that entire ecosystem is unavailable. The folk remedy is a cron container gluing together certbot or acme.sh, the az CLI, and OpenSSL: three tools, a shell, a writable filesystem, and secrets passing through temp files. It works, and I’ve seen it work badly. I wanted the same outcome with one static binary and nothing else in the image.
The one decision that makes it clean#
The core trick is that the ACME challenge is answered from inside the environment being certified. The nginx proxy that already owns ingress routes by path anyway, so one more rule sends /.well-known/acme-challenge/* to the ACME container while every other request keeps hitting the apps. When Let’s Encrypt validates the domain, it talks to the same hostname, same load balancer, same front door as real user traffic. There is no side channel to build and nothing extra exposed to the internet — the proxy that disqualified managed certificates is exactly what makes self-managed ACME trivial.
That’s also the answer to “why HTTP-01 and not DNS-01”. DNS-01 would mean giving the service write access to the DNS zone — and an identity that can rewrite DNS records has a much larger blast radius than one that can import certificates into a single vault. HTTP-01 keeps the permission set minimal: one Key Vault, certificate import, nothing else.
HTTP-01 has a price, and it’s worth naming honestly: challenge tokens are held in memory, so the service must run as exactly one replica — a second instance would answer challenges with a 404. I could have added shared state to lift that constraint. I chose not to: a certificate helper that renews once a month does not need a coordination layer, it needs fewer failure modes. Documenting a constraint beats engineering around it when the constraint costs you nothing in practice.
Decisions I’d defend in review#
PEM to PFX in memory. Key Vault imports certificates as PFX, and the obvious way to get one is to shell out to OpenSSL. That single decision would have dragged a shell, a package manager, and a writable filesystem into the image — and private key material through temp files. Doing the conversion in process (for both RSA and ECDSA keys) is what lets the image stay distroless and run as nonroot, with the key never existing outside process memory.
The ACME account key is persisted. A naive client registers a fresh Let’s Encrypt account on every restart. Nothing visibly breaks — but Let’s Encrypt rate-limits new registrations per IP, and that failure mode surfaces at the worst possible moment: during a restart loop caused by some unrelated incident. The account key lives in Key Vault as a secret, so restarts stay boring, as they should be.
A failed check aborts; it never “plays it safe” by reissuing. If Key Vault throws a transient error, the safe-looking move — “couldn’t confirm the cert, let’s just get a new one” — is actually the dangerous one: Let’s Encrypt allows five duplicate certificates per week, and a flapping dependency could spend that budget in an afternoon. A failed cycle retries an hour later instead. Rate limits are a production dependency; the design has to treat them like one.
Status is data, not vibes. Renewal is a slow loop — by default it wakes once a day and usually decides to do nothing. A liveness probe is useless for answering “did the last renewal actually work?”, so the process is alive at /healthz but accountable at /status, which returns the outcome of the last cycle as JSON. Anything — a dashboard, a scheduled probe, a colleague with curl — can check it without a metrics stack.
What it refuses to do#
No wildcard certificates (that’s DNS-01 territory, with the DNS-zone credentials it implies). No multi-replica high availability. No support for other clouds. Every one of those would be more code standing between Let’s Encrypt and your TLS binding, and a certificate tool earns trust by being small enough to audit in an evening. It does one path — Let’s Encrypt → PFX → Key Vault → Container Apps binding — and does it predictably.
Operational notes#
A few realities of running ACME against production Let’s Encrypt, now encoded in the README’s troubleshooting section:
- Validation starts against the staging CA. The production limit of five failed validations per domain per hour leaves no room for iterating on ingress configuration. The staging endpoint exercises an identical flow without spending that budget.
- The challenge route is a precondition, not a follow-up. A missing route doesn’t just delay issuance — every timeout counts against the validation limit. The deployment order treats routing as a hard prerequisite of the first run.
- Import and binding are separate events. A renewed certificate is visible in Key Vault immediately; the Container Apps domain binding refreshes within a few minutes. Monitoring should treat that window as normal propagation, not as a failure to investigate.
One broader principle: a tool that holds the keys to your TLS should meet the supply-chain bar it helps you enforce. Releases ship as distroless multi-arch images with SBOMs and provenance, signed with cosign, and a weekly job rescans the published image, so a CVE discovered after release surfaces without waiting for the next commit.
If you have the same gap#
The project is open source under Apache 2.0: github.com/emilgruzalski/acme-az-aca. The README will get you from zero to a renewed certificate; issues and PRs are welcome. And if your setup fits managed certificates — genuinely, use those.