Skip to main content

One DV Certificate Per Tenant Domain vs Wildcard TLS: The Multi-Tenant Pattern a Self-Hosted PaaS Actually Needs

9 min readDora NodaDora Noda
Share

The wildcard trick that made your first hundred tenants easy stops working the moment tenant one hundred and one asks for app.theirdomain.com instead of their-name.yourplatform.com. A single *.yourplatform.com certificate covers every subdomain you hand out — it covers nothing a tenant brings from outside your zone. Once "bring your own domain" is a real feature, the pattern that actually scales is one DV certificate issued per tenant domain, triggered the first time a tenant binds it, not a shared wildcard.

That sentence is easy to agree with and hard to implement. This post is the implementation: what bind-time issuance, renewal at scale, and offboarding revocation each concretely require, using cert-manager (Kubernetes) and acme.sh (everything else) as the two real paths a self-hosted PaaS operator will choose between.

Why "Just Wildcard Everything" Stops Working

TLS certificate selection at the edge runs on SNI — the client tells your server which hostname it's connecting to during the handshake, and your proxy picks the matching certificate before the connection is even decrypted. A wildcard certificate for *.yourplatform.com matches exactly one pattern: single-label subdomains of a domain you control. tenant-a.yourplatform.com matches. app.tenant-a.com does not, and neither does app.tenant-a.com's own subdomain — wildcards don't nest. The moment a tenant owns the domain, you need a certificate that names it, and only that tenant's domain can authorize one.

The tempting shortcut is to keep pretending everything is a subdomain of your own zone — CNAME the tenant's domain to you, then request certificates for it the same way you'd request one for a subdomain. This runs straight into a rate limit Let's Encrypt enforces per registered domain: 50 certificates issued per week, counted against the domain that owns the name being certified. If you're requesting all of those certificates under your own account against names that resolve through your infrastructure, a fast onboarding week — 60, 80, 200 new tenant bindings — can blow through that limit and start failing issuance for everyone behind it, not just the newest arrivals.

Genuine per-tenant custom domains sidestep this specific wall by construction. app.tenant-a.com is a certificate against the registered domain tenant-a.com, not yourplatform.com — a completely separate rate-limit bucket that the tenant, not you, would need to exhaust. The limit that actually governs you at scale is a different one: 300 new ACME orders per account per three hours, which caps how fast you can request certificates regardless of whose domain they're for. That's a real ceiling during a large onboarding burst, and it's the number that should drive your issuance queue design — not the per-domain limit, which per-tenant DV certificates already dodge.

Part 1: Bind-Time Issuance

The bind-time flow is the same regardless of stack: a tenant adds a domain, your control plane verifies they control it, then triggers an ACME order that completes a challenge and returns a certificate your edge can serve. The verification and the ACME client differ by infrastructure.

On Kubernetes, cert-manager is the default answer. Model each tenant domain as its own Certificate custom resource, referencing an Issuer configured for either an HTTP01 or DNS01 solver. A controller watches these resources and drives issuance automatically — when a tenant binds app.tenant-a.com, your API creates a Certificate object naming it, and cert-manager takes it from there, storing the resulting cert/key pair as a Kubernetes Secret your ingress or gateway already knows how to mount. The one piece you still own: gating creation of that Certificate resource behind actual domain-ownership proof (DNS TXT record or a served file token) before you ask cert-manager to issue — cert-manager will happily attempt (and fail, and retry, and eventually get rate-limited) against a domain the tenant doesn't control.

Off Kubernetes — a bare VM or docker-compose deployment fronted by nginx or HAProxy — acme.sh is the equivalent primitive. It's a single shell script with no daemon, and it's built exactly for this trigger-per-event pattern: your bind API shells out to acme.sh --issue -d app.tenant-a.com --webroot /var/www/acme-challenge (HTTP-01) or --dns dns_api (DNS-01, if you're managing the tenant's DNS on their behalf) at the moment of binding, then chains a --deploy-hook that drops the resulting cert where nginx expects it and reloads. There's no controller loop watching state; the cron entry acme.sh installs on setup is what re-triggers issuance near expiry, calling the same deploy hook. For a self-hosted PaaS that isn't running Kubernetes, this is less machinery, not less capability — the ownership-verification gate you build in front of it is identical to the cert-manager case.

Which one you pick isn't a matter of taste: it's downstream of whether your control plane is already a Kubernetes API or not. A Cluster API/Hetzner-style platform gets cert-manager close to free, since it's already reconciling everything else as CRDs; a lighter single-host or fleet-of-VMs PaaS gets more mileage from acme.sh's zero-daemon footprint than from standing up a Kubernetes control plane just to hold certificates.

HTTP-01 is the better default challenge type for both paths when the tenant domain simply points at your platform's IP or load balancer — it needs no access to the tenant's DNS provider, just a file served at a well-known path (or a specific response on your edge, for acme.sh's ingress-adjacent modes). Reach for DNS-01 only when you need a wildcard for your own platform subdomains, or when a tenant's traffic passes through a CDN/WAF that would intercept the HTTP-01 challenge before it reaches your origin.

Part 2: Renewal Fan-Out at Scale

Bind-time issuance is the easy 10% — one event, one certificate. Renewal is the part that actually has to run correctly forever, across however many tenant domains you're carrying, and it's about to get twice as demanding.

Let's Encrypt is rolling out shorter certificate lifetimes on a published timeline: an opt-in 45-day profile went live May 13, 2026; the default ACME profile moves to 64-day certificates (with authorization reuse cut from 30 days to 10) on February 10, 2027; and the default drops to 45-day certificates with only a 7-hour authorization reuse window by February 16, 2028. Halving the certificate lifetime roughly doubles renewal frequency — a platform carrying 5,000 tenant domains that currently issues on the order of 55–80 renewals a day (90-day certs, staggered) will be issuing on the order of 110–160 a day once 45-day certificates are the default, with a much thinner margin for a missed run before something is actually expired and serving broken TLS to a tenant's visitors.

Two concrete things make that renewal fan-out tractable instead of a ticking incident queue:

  • ACME Renewal Information (ARI, RFC 9773) lets the CA tell your client exactly when to renew, via a renewalInfo endpoint returning a suggested window per certificate, instead of every client independently computing "30 days before expiry" and all converging on the same day. Certbot has supported it since 4.1.0; cert-manager and acme.sh users should confirm their client version honors it rather than a hardcoded interval, because ARI is precisely the mechanism that turns a fleet of thousands of tenant certificates that all renew Tuesday into a fleet that renews on a CA-recommended, staggered schedule.
  • Renewal-failure visibility that outlives a Kubernetes event. cert-manager surfaces failed renewals as Certificate resource conditions and as Kubernetes Events — and Events expire after one hour by default, which is not a monitoring system, it's a rumor. Scrape the Prometheus metrics cert-manager already exposes instead: certmanager_certificate_ready_status, certmanager_certificate_expiration_timestamp_seconds, and certmanager_certificate_renewal_timestamp_seconds, alerting on any tenant certificate whose expiration timestamp is inside the renewal window with no corresponding recent renewal. acme.sh deployments need the equivalent outside Kubernetes: alert on the cron job's exit code and on certificate notAfter dates directly, since there's no controller reconciling state for you.

Part 3: Revocation on Tenant Offboarding

When a tenant unbinds a domain — cancels, migrates away, or gets removed for abuse — the instinct is to call the ACME revocation endpoint and consider it handled. Do that, but don't stop there: revocation checking is enforced client-side via OCSP or CRLs, both of which browsers and HTTP clients frequently soft-fail on (treating a revocation check they can't complete as "valid" rather than blocking the connection). A revoked certificate can keep working, in practice, for a meaningful window after you've revoked it.

The control that's actually enforced immediately is simpler: stop terminating TLS for that hostname at your edge. Remove the tenant's domain from your ingress/gateway config (cert-manager path) or from the on-demand allow-list your reverse proxy consults before serving a cert for an SNI it hasn't seen before (the pattern tools like Caddy's on-demand TLS use — an internal "may I issue/serve for this hostname" lookup against your tenant database, gating both issuance and serving). Do this first, and treat the ACME revocation call as defense-in-depth cleanup that happens alongside it, not the primary safeguard. If a certificate for an offboarded tenant domain is never presented by your infrastructure again, whether or not a CRL check catches a cached copy elsewhere stops being your platform's exposure.

The Reference Shape

Put together, a self-hosted PaaS's custom-domain feature needs five pieces:

  • An ownership-verification gate in front of issuance (TXT record or served-token check), run before either cert-manager or acme.sh is invoked.
  • HTTP-01 as the default challenge, with DNS-01 reserved for your own wildcard subdomains or CDN-fronted tenant domains.
  • A bind-time issuance queue rate-limited well under 300 orders/3 hours, so a burst of new signups degrades to "issuing over the next few minutes" instead of failing outright.
  • ARI-aware renewal on whichever client you run, monitored through metrics that outlive an hour.
  • An offboarding step that pulls the domain from your serving layer immediately, with ACME revocation alongside it rather than in place of it.

None of these five pieces is exotic — cert-manager and acme.sh both ship the primitives — but "just wildcard everything" was never going to get you past the first tenant who owns their own domain, and the five pieces above are what replaces it.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with custom domains handled by exactly this per-tenant DV pattern rather than a shared wildcard. Star the repo on GitHub or bind your first custom domain today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex