MAR 10, 20264 MIN READ

Debugging Custom Domain SSL with Go's autocert

When building a multi-tenant SaaS where users can point their own domains at your app, getting SSL certificates provisioned automatically sounds simple. In practice, there are several layers that can go wrong. Here's what we ran into.

Martin Binder

Martin Binder

bndrmrtn@gmail.com

The Setup

The stack: a Go server using golang.org/x/crypto/acme/autocert to automatically provision Let’s Encrypt certificates for custom domains, sitting in front of an nginx proxy, running in Docker.

Users add a CNAME record pointing their domain to our server, and autocert handles the rest — at least in theory.

The Symptoms

Visiting a custom domain returned a generic “page isn’t redirecting properly” browser error. The kind of error that could mean anything.

Layer 1: Cloudflare Was in the Way

The first clue came from curl:

curl -v https://blog.mrtn.vip
# IPv4: 172.67.175.18, 104.21.64.33  ← Cloudflare IPs, not our server
# server: cloudflare
# 301 → https://blog.mrtn.vip/       ← redirecting to itself

The user had set up their CNAME to point to mrtn.wizzl.app, which itself was proxied through Cloudflare (orange cloud). So all traffic was hitting Cloudflare’s network, never reaching our server. Cloudflare had no certificate for blog.mrtn.vip and was returning a redirect loop of its own.

The fix: switch the CNAME destination to a grey-cloud (DNS-only) record that resolves directly to the server IP.

dig blog.mrtn.vip A
# blog.mrtn.vip → mrtn.wizzl.app → 46.225.17.102  ✓ real server IP

Layer 2: Let’s Encrypt Was Using the Wrong Challenge Type

With DNS fixed, the TLS handshake now failed with a different error in the server logs:

tls: client requested unsupported application protocols (["acme-tls/1"])

This is Let’s Encrypt attempting TLS-ALPN-01 — a fallback challenge type that autocert does not support. Let’s Encrypt only falls back to this when the standard HTTP-01 challenge fails. Something was preventing it from completing over port 80.

Layer 3: The Domain Wasn’t Being Served the Challenge

Curling port 80 directly showed our server returning a 301 redirect to HTTPS for the custom domain — even for /.well-known/acme-challenge/ paths.

This shouldn’t happen. m.HTTPHandler(httpHandler) is supposed to intercept ACME challenge paths before passing requests to our handler. But there’s a catch: autocert only serves the challenge token if the host passes HostPolicy. If HostPolicy rejects the host, the challenge is never served and the request falls through to our redirect.

Layer 4: The Actual Bug

HostPolicy calls store.DomainAllowed(ctx, host), which checks an in-memory cache warmed up at startup. The domain was in the database, but a subtle mismatch in the cache lookup meant it was returning false — causing HostPolicy to reject the domain, autocert to skip the challenge, and the HTTP-01 verification to fail every time.

Once the cache lookup was fixed, the full flow worked:

curl http://blog.mrtn.vip/.well-known/acme-challenge/test
→ acme/autocert: certificate cache miss  ✓ (server is reachable, no token yet)

curl https://blog.mrtn.vip
→ autocert provisions cert via HTTP-01
→ TLS handshake succeeds
→ request proxied to nginx

Lessons

  • A CNAME through a Cloudflare-proxied domain inherits the proxy. If your origin is orange-clouded, any CNAME pointing to it is also proxied. Custom domains need a grey-cloud DNS-only target to reach your server directly.
  • acme-tls/1 in your logs means HTTP-01 is broken, not that you need to support TLS-ALPN-01. Fix the HTTP side.
  • autocert won’t serve challenges for hosts rejected by HostPolicy. The rejection is silent from the client’s perspective — it just looks like a redirect loop.
  • Test your cache warm-up logic carefully. A domain in the database but missing from the in-memory cache is invisible to HostPolicy and causes the entire cert provisioning flow to silently fail.