Skip to main content

Building an ACME Client for Azure Container Apps

·810 words·4 mins
Author
Emil Grużalski
Cloud Infrastructure & Automation

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:

  1. 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.
  2. Requests a certificate from Let’s Encrypt over ACME.
  3. 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.
  4. Converts PEM to PFX in memory, with no OpenSSL dependency.
  5. Imports the PFX into Key Vault via the Azure SDK. Container Apps then picks it up for custom-domain bindings.
  6. 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.