DEV Community

Pingvera.com
Pingvera.com

Posted on

Serving your SaaS on customer domains: CNAME, certbot, and 300 lines of Go instead of ACME on-demand

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, ~70 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 /
Enter fullscreen mode Exit fullscreen mode

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, ""
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

3. The root helper: 70 lines of 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

    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, keep the certificate
for conf in "$NGINX_DIR/status-"*.conf; do
    d=$(basename "$conf" .conf); d="${d#status-}"
    [ -n "${want[$d]:-}" ] || { rm -f "$conf"; changed=1; }
done

[ "$changed" = 1 ] && nginx -t && systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

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 are not revoked. When a user removes a domain, the helper only deletes the nginx block. The certificate on disk is harmless, and from then on it lives its own life: if the customer moved their CNAME away, the next certbot renew fails HTTP-01 and the certificate quietly expires in 90 days; if the CNAME is still there, it keeps renewing for nothing. I consciously preferred both outcomes to revocation logic and its edge cases (what if the domain comes back a day later?). The only price is some noise in the renew logs.

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 → verified transition — 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.

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)