The gap I kept running into#
Issuing and renewing TLS certificates is a solved problem — until you put it in a slightly unusual place. The combination I cared about was this: a Let’s Encrypt certificate that lands directly in Azure Key Vault, renewed automatically, with the ACME HTTP-01 challenge handled from inside an Azure Container Apps environment.
That sounds mundane, but when I went looking for something to do it, I came up empty. The usual ACME tooling assumes one of a few worlds:
- cert-manager — excellent, but it lives in Kubernetes and expects a cluster, CRDs, and the operator pattern. Container Apps is serverless; there is no cluster to install it into.
- Caddy / Traefik / nginx + acme.sh — these terminate TLS at a reverse proxy and store the certificate on local disk. That is the opposite of what I wanted: the certificate needs to be in Key Vault so Container Apps can bind it to a custom domain.
- certbot and friends — built around a VM or a host with a filesystem and a cron job, not a distroless, non-root, ephemeral container.
None of them put the certificate where Azure Container Apps actually reads it from for managed custom-domain TLS: Key Vault. You can stitch a pipeline together — run certbot somewhere, export the PFX, push it to Key Vault with a script, schedule the whole thing — but that is a fragile chain of moving parts for something that should be a single long-lived process.
So I built that single process. I wrote it on my own time, as an open-source exercise, and published it under Apache 2.0 at github.com/emilgruzalski/acme-az-aca. It’s a generic tool for a generic problem — nothing about it is specific to any particular deployment.
What it actually does#
The application is a small Go daemon that runs alongside your apps in a Container Apps environment. You add one ingress rule that routes /.well-known/acme-challenge/* to it, and it takes care of the rest.
On each cycle (every 24 hours by default) it:
- Reads the current certificate from Azure Key Vault and checks its expiry against a renewal threshold (30 days by default). If it’s still healthy, it goes back to sleep.
- Requests a certificate from Let’s Encrypt over ACME.
- Answers the HTTP-01 challenge from its own built-in HTTP server — Let’s Encrypt hits
http://<domain>/.well-known/acme-challenge/<token>, the ingress rule routes that to this container, and it returns the key authorization. - Converts PEM to PFX in memory, with no OpenSSL dependency.
- Imports the PFX into Key Vault via the Azure SDK. Container Apps then picks it up for custom-domain bindings.
- Sleeps until the next check.
If anything fails and notifications are enabled, it sends an SMTP error report and retries on the next cycle.
A few implementation notes#
I deliberately kept the dependency surface small. A few decisions worth calling out:
Lego for the ACME protocol. Rather than reimplement ACME, I used go-acme/lego and implemented its challenge.Provider interface with a tiny in-memory token store that is also an http.Handler. The same struct both records the challenge token and serves it:
func (p *challengeProvider) Present(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.tokens[token] = keyAuth
return nil
}
func (p *challengeProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
p.mu.RLock()
keyAuth, ok := p.tokens[token]
p.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
w.Write([]byte(keyAuth))
}No disk, no temporary files — the challenge response only ever exists in memory for the duration of the verification.
PFX conversion without OpenSSL. Key Vault imports certificates as PKCS#12 (PFX). Shelling out to openssl would have meant a fatter image and a shell in the container, so I convert PEM to PFX in process using go-pkcs12 with modern AES-256 encoding, supporting both RSA and ECDSA keys.
DefaultAzureCredential for auth. In Container Apps you assign a Managed Identity and the daemon authenticates with zero secrets. Locally, or wherever a Managed Identity isn’t available, it falls back to a Service Principal via the usual AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET variables.
Built for a serverless container. It’s a single long-lived process with a /healthz endpoint, graceful shutdown on SIGINT/SIGTERM, structured logging via log/slog, and it ships as a ~20 MB distroless, non-root image. That’s the shape Container Apps wants, rather than a VM-and-cron shape.
Why open-source it#
The problem isn’t unique to me — anyone running Azure Container Apps with a custom domain and wanting free, automatically renewed TLS hits the same wall. There was no tool, so the useful thing to do was to write a clean, generic one and give it away rather than keep re-solving it privately. It’s a self-contained side project: a single binary, a clear README, an Apache 2.0 license, and CI on GitHub.
If you’re in the same spot, the code, the architecture diagram, and the deployment steps are all here: github.com/emilgruzalski/acme-az-aca.