There is a class of features that look like one line in a ticket and turn into a week of work. "I want the status page on my own domain" is one of them. The customer adds a CNAME record — status.client.com → status.your-service.com — and then the interesting part begins: who issues a TLS certificate for a domain you don't own, who renews it, how do you keep one user from burning your Let's Encrypt rate limit with garbage domains, and why X-Forwarded-For can quietly turn an internal endpoint into a public one.
I built this for a monitoring service — it has public status pages, and web agencies asked to serve them white-label, from their clients' own domains. Here is the scheme I picked, the actual code, and the rakes I stepped on or nearly did. The setting is deliberately pragmatic: a single server, nginx in front of a Go application, no Kubernetes.
Why not ACME on-demand
The first thing that comes to mind is Caddy or openresty with auto-ssl: a request arrives with an unknown Host, you issue a certificate on the fly, cache it, done. The scheme works, but it has three properties I didn't like.
Private keys end up in the application's hands. On-demand issuance means the process that terminates TLS can talk to Let's Encrypt and stores the keys. If that's your application server, it now has too many privileges. If it's a separate Caddy, you've acquired another stateful component to back up and monitor.
DoS via garbage Host headers. Anyone who points a CNAME at your IP (or simply sends a request with a made-up Host) makes you call Let's Encrypt. LE's limit is 300 new orders per account per 3 hours. Letting strangers exhaust it means leaving your real customers without certificates. On-demand setups defend against this with an allow-list backed by a database — but then "on the fly" isn't really on the fly anymore: you get the same "check the domain in the DB, then issue" pipeline I have, except it runs inside a TLS handshake, which is the worst place for it.
Issuing during the handshake is the worst possible moment. The first visitor pays for certificate issuance with seconds of waiting, and if LE happens to be down, they get a dropped connection.
Meanwhile I had nothing to lose by going asynchronous: the domains are already in the database (users type them into a settings form), so certificates can be issued ahead of time, calmly, on a schedule. Asynchrony here isn't a compromise — it honestly reflects reality: between "typed the domain into settings" and "added the CNAME at the registrar" there are minutes, sometimes days.
The whole scheme
Four parts, each small:
user types in a domain
│
▼
[1] input normalization & validation (Go, ~40 lines)
│ domain_status = 'pending'
▼
[2] background DNS verifier (Go, goroutine, every 5 min)
│ CNAME points at us? → 'verified'
▼
[3] root helper on a systemd timer (bash, ~170 lines)
│ certbot webroot → nginx block from template → callback 'active'
▼
[4] HostRouter in the application (Go, ~50 lines)
request with Host = client.com → serve their page at /
The role separation is strict: the application never touches certificates or nginx; the helper never touches the database. They talk through two loopback-only endpoints. Now part by part.
1. Input validation: boring until it hurts
The user types a domain into a text field, and that string will later end up in SQL, in an nginx config filename, and in certbot's arguments. So normalization is not cosmetics:
func normalizeCustomDomain(s string) (string, string) {
d := strings.ToLower(strings.TrimSpace(s))
if d == "" {
return "", "" // empty = remove the domain; that's valid
}
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
if i := strings.IndexAny(d, "/?#"); i >= 0 {
d = d[:i]
}
if i := strings.IndexByte(d, ':'); i >= 0 { // drop the port
d = d[:i]
}
d = strings.TrimSuffix(d, ".")
for _, r := range d {
if r > 127 {
return "", "please use the punycode form (xn--...) for IDN domains"
}
}
if len(d) > 253 || !hostnameRe.MatchString(d) {
return "", "not a valid hostname"
}
// never serve customer pages on our own domains
for _, own := range []string{"your-service.com", "your-service.io"} {
if d == own || strings.HasSuffix(d, "."+own) {
return "", "service domains cannot be used"
}
}
return d, ""
}
Three decisions worth spelling out.
Non-ASCII is rejected, not converted. I could have silently run IDNA and accepted a Unicode domain. I deliberately ask for punycode instead: the user must see exactly the string they will configure at their registrar — otherwise they'll spend the next hour figuring out why the CNAME "doesn't work" while comparing Unicode to xn--.
Banning your own domains. Without this check a user types app.your-service.com, passes DNS verification (the domain really does point at your IP) — and their status page hijacks your dashboard. One line of code against a whole class of trouble.
The empty string is a valid value. That's how a user removes a domain. Easy to forget, and you end up with a form the domain can never be removed from.
After saving, the domain gets status pending — and the synchronous part ends there. No checks in the HTTP handler: DNS isn't configured yet, and that's fine.
2. The DNS verifier: CNAME, apex domains, and hysteresis
A goroutine walks all domains every 5 minutes:
func (s *Server) checkDomainDNS(ctx context.Context, domain string) (bool, string) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
res := net.DefaultResolver
if cname, err := res.LookupCNAME(ctx, domain); err == nil {
if strings.TrimSuffix(strings.ToLower(cname), ".") == s.cnameTarget {
return true, ""
}
}
// fallback for apex domains, where CNAME is impossible:
// the domain's A records intersect with the target's A records
want, err1 := res.LookupHost(ctx, s.cnameTarget)
got, err2 := res.LookupHost(ctx, domain)
if err1 != nil {
return false, "our CNAME target does not resolve (problem on our side)"
}
if err2 != nil {
return false, "domain does not resolve — add a CNAME to " + s.cnameTarget
}
for _, ip := range got {
if contains(want, ip) {
return true, ""
}
}
return false, "domain's DNS does not point at us — a CNAME to " + s.cnameTarget + " is required"
}
The A-record fallback is mandatory. The RFCs forbid a CNAME at the apex (where SOA/NS records already live), so client.com — unlike status.client.com — can only point at you with an A record. Checking "do the domain's A records intersect with the target's" covers that case, plus DNS providers with ALIAS/ANAME records that flatten the CNAME on their side.
Error texts are UI. The verifier writes the reason into the database; the interface shows it to the user: "domain does not resolve — add a CNAME to ...". Distinguishing "you haven't added the record yet" from "our target is broken" is a small courtesy that saves support conversations.
And the most important decision at this layer is hysteresis. A status, once reached, is never downgraded:
newStatus := r.status
if ok && r.status == "pending" {
newStatus = "verified"
}
// !ok on verified/active → record domain_error, do NOT touch the status
DNS is an environment where something blinks all the time: registrar maintenance, a resolver hiccup, a TTL expiring at the wrong moment. If every failed lookup deactivated the domain, a customer's perfectly working page would randomly fall over. So a temporary DNS blip leaves the domain active (the certificate is on disk, the nginx block is in place) — while a warning appears in the error field for the owner.
The flip side: if the customer permanently moves their DNS away, the page dies honestly on its own — browsers simply stop arriving at our IP. There's nothing to deactivate.
One caveat that was missing from the first revision of this article: "don't deactivate the page" and "keep maintaining TLS for it" are two different things. The database row can indeed be left alone, but the certificate for a domain that no longer points at you must be dropped — otherwise renewal will keep failing forever. Details below, in the helper section; that's also where the hysteresis lives that tells a hiccuping resolver apart from a domain that's gone for good.
3. The root helper: bash with tight permissions
Issuing certificates and reloading nginx requires root — but I didn't want to hand those privileges to the application. So this job lives in a separate script on a systemd timer (every 5 minutes) that talks to the application over loopback HTTP:
json=$(curl -sf --max-time 10 "$APP_URL/internal/status-domains") || exit 0
domains=$(echo "$json" | grep -o '"domain":"[^"]*"' | cut -d'"' -f4)
for d in $domains; do
# injection guard for paths/configs: strictly a hostname
[[ "$d" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || continue
want["$d"]=1
# does DNS still point at us? otherwise HTTP-01 is guaranteed to fail,
# and LE counts failed validations against the whole account (see below)
dns_points_here "$d" || { mark_dns_fail "$d"; continue; }
if [ ! -d "/etc/letsencrypt/live/$d" ]; then
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$d" \
--non-interactive --agree-tos --keep \
--cert-name "$d" --deploy-hook "systemctl reload nginx" \
|| continue # failed — we'll retry next cycle
fi
conf="$NGINX_DIR/status-${d}.conf"
[ -f "$conf" ] || { sed "s/__DOMAIN__/$d/g" "$TEMPLATE" > "$conf"; changed=1; }
curl -sf -X POST -d "{\"domain\":\"$d\"}" \
"$APP_URL/internal/status-domains/activate" >/dev/null
done
# domains the user removed: drop the config AND the certificate together
# with its renewal profile, or certbot.timer will renew it forever
for src in "$NGINX_DIR/status-"*.conf "$STATE_DIR"/*.owned; do
d=$(domain_from "$src")
[ -n "${want[$d]:-}" ] && continue
rm -f "$NGINX_DIR/status-${d}.conf"; changed=1
certbot delete --cert-name "$d" --non-interactive
done
[ "$changed" = 1 ] && nginx -t && systemctl reload nginx
What deserves attention here.
The domain regex is a second line of defense. Yes, the application already validated the input. But the helper runs as root and substitutes the string into a file path and a shell command — re-checking the format costs nothing compared to cleaning up afterwards. The rule is simple: every component validates its inputs itself, even when "nothing bad can possibly come from there."
Certificates of removed domains must be deleted along with their renewal profile. This is where the first revision of this article was wrong, and a reader pointed it out. I claimed a leftover certificate was harmless and that "the only price is some noise in the renew logs." That is not true.
Renewal is driven by the system certbot.timer, not by the helper — twice a day, across every renewal profile in /etc/letsencrypt/renewal/. By deleting only the nginx block, the helper left the profile behind, so an abandoned domain kept being renewed forever, failing HTTP-01 every time. And Let's Encrypt counts failed validations against the entire account, not the individual domain: accumulate a critical mass of abandoned customer domains and issuance stalls for everything, including your own domains.
I went looking after that comment and found it in production. One custom domain had been moved by DNS to another of our servers, while the original one still held the database row, the nginx block and the renewal profile: 7 renewal failures in three days, 1 renew failure(s) in every nightly run.
Digging in, the hole turned out to be wider than the cleanup step suggests. The issuance branch reads: "no directory under /etc/letsencrypt/live/$d → call certbot." If DNS is moved away before the first successful issuance, that directory never appears — and the timer runs every 5 minutes, which is 288 requests to LE per day for a single domain. The failed-validation limit (5 per hour per domain) is exhausted within the first half hour, and from there the block hits the account.
The fix came in four parts:
# 1) preflight DNS check: don't go to LE if the domain no longer points at us
dns_points_here "$d" || { mark_dns_fail "$d"; continue; }
# 2) backoff between issuance attempts — the timer fires every 5 minutes
last=$(cat "$STATE_DIR/$d.lastissue" 2>/dev/null || echo 0)
[ $(( $(date +%s) - last )) -lt "$ISSUE_RETRY_S" ] && continue
# 3) removing a domain deletes the certificate WITH its renewal profile
certbot delete --cert-name "$d" --non-interactive
# 4) only delete what we issued ourselves (an "owned" registry), so the
# server's own certificates are never touched
The fourth point is the one that's easy to miss in a naive implementation: the server holds more than customer certificates. A cleanup that sweeps all of /etc/letsencrypt/renewal/ will one day take out the certificate of your main domain. So the helper marks what it issued and touches only what it marked.
Revocation (certbot revoke), though, I still declined: revocation is for a compromised key, not for "the customer changed their mind." Deleting the profile is enough — the certificate simply expires in 90 days, affecting no one.
nginx -t before reload is non-negotiable. One broken config (say, the certificate got issued but the files didn't finish writing) without that check takes down the reload for every site on the server.
HTTP-01 validation of other people's domains needs one more piece — a catch-all on :80:
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
When LE comes to validate status.client.com, the request arrives — via the customer's CNAME — at our port 80, and it must find the challenge file certbot placed in the shared webroot. Without the default_server block, certbot passes validation only for domains that already have their own config — that is, never for new ones.
DNS-01 instead of HTTP-01 is not an option here, by the way: it requires writing TXT records into the customer's zone, which we have no access to and never will. Wildcards are out too — the domains are other people's, each customer has their own.
4. HostRouter and the loopback trap
What's left is serving the page. nginx terminates TLS; the per-domain server block template is a plain proxy_pass to the application with proxy_set_header Host $host. Inside the application, requests with a foreign Host are intercepted by a wrapper around the root mux:
func (s *Server) HostRouter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slug, ok := s.customDomainSlug(r.Context(), r.Host) // 30s cache
if !ok {
next.ServeHTTP(w, r)
return
}
switch {
case r.URL.Path == "/" || r.URL.Path == "":
r2 := r.Clone(r.Context())
r2.URL.Path = "/status/" + slug
next.ServeHTTP(w, r2)
case strings.HasPrefix(r.URL.Path, "/status/"):
next.ServeHTTP(w, r) // page assets: charts, RSS, badge
default:
http.NotFound(w, r) // never expose the dashboard or API on foreign domains
}
})
}
The key line is the final default. A customer's domain hosts exactly one page and its assets; everything else is a 404. Without it, status.client.com/login would open your dashboard login — on the customer's domain, with the customer's valid certificate. Let's not hand phishers that gift.
The Host → page resolution is cached wholesale for 30 seconds — there are dozens of domains, the table is tiny, and the hot path makes zero database queries. The cache includes verified domains, not just active ones: the certificate may already be issued while the helper's activate callback hasn't landed yet — the page should come alive on the very first TLS request, not after the next timer cycle.
And the last rake is the sneakiest. The helper talks to the application over loopback, and the /internal/* endpoints must be reachable by it alone. The first version of the check looked obvious:
ip := remoteIP(r)
if !ip.IsLoopback() { http.NotFound(w, r); return }
The problem: nginx also proxies from 127.0.0.1. An external request to https://your-service.com/internal/status-domains arrives at the application with a loopback address — and the check waves it through. You can tell them apart by the proxy headers: nginx always sets them (that's its config), the helper's direct curl never does:
if ip == nil || !ip.IsLoopback() ||
r.Header.Get("X-Forwarded-For") != "" || r.Header.Get("X-Real-Ip") != "" {
http.NotFound(w, r)
return
}
Yes, this leans on nginx's configuration — remove proxy_set_header X-Real-IP and the guard weakens. For an internal endpoint on a single server the trade-off is acceptable; the paranoid version is a separate listener bound to 127.0.0.1 only, or a unix socket.
The scheme's limits, honestly
Everything above is single-server pragmatism. The applicability boundaries:
- Multiple servers behind a load balancer — a helper with local certbot no longer works: every node needs the certificate. Then it's either centralized certificate storage (and you're halfway to Caddy/cert-manager) or TLS termination at the balancer with its own ACME.
- Thousands of domains — "walk all domains every 5 minutes" and "cache the whole map in memory" stop being funny. You'd need a queue and incremental processing.
-
The LE rate limit still exists: 300 orders per 3 hours. In my setup issuance only happens on the
pending → verifiedtransition — that is, after ownership is confirmed — so it's hard to hit. But a mass onboarding (migrating a hundred domains in one evening) would hit it; for that case, keep a pause between issuances. - The failed-validation limit is more dangerous than the order limit. This is what I underestimated in the first revision (see the cleanup discussion above). HTTP-01 failures accumulate against the account, not the domain, and they don't come from a traffic spike — they come from abandoned domains piling up, which makes them harder to notice the longer the service lives. Hence the rule: every ACME call needs a precondition (DNS still points at us) and a frequency ceiling, and every removed domain must take its renewal profile with it.
At my scale — dozens of domains, one server — the scheme runs unnoticed: a user types a domain, adds a CNAME, and about ten minutes later the page opens over https on their domain. No part exceeds a hundred lines, each can be read over a cup of coffee, and any one of them failing doesn't take down the rest: if the timer stops, new domains don't activate, but every working one keeps working.
To me, that's the main quality bar for infrastructure code you touch twice a year.
This is a translation of my article originally published on Habr (in Russian).
Top comments (0)