Custom Domains for Your SaaS with Caddy On-Demand TLS and ASP.NET Core
If you build SaaS for long enough, somebody will ask for custom domains. Their your.customdomain.com should hit your platform and serve their content, and they want it to feel like their own. The hard part is not the routing. The hard part is TLS.<br>You cannot pre-issue certificates for hostnames you do not know about yet, and you cannot ask users to upload their own certs. The answer is on-demand TLS, where your edge proxy issues a Let's Encrypt cert the first time a hostname is requested, on the condition that you authorize it.<br>Cloudflare for SaaS solves this commercially. It also has per-hostname pricing that adds up fast once you cross the free tier. For Reslug, I run Caddy on a small Hetzner VPS instead. It costs me a few euros a month and handles every Pro customer's custom domain.<br>This is the full setup: the Caddyfile, the ask endpoint that authorizes hostnames, and the ASP.NET Core middleware that resolves the incoming Host header to a workspace. Code is .NET 9, EF Core with PostgreSQL.<br>The architecture<br>Three components, in this order:
Follow the top row of the diagram, left to right: that is the request path. The customer's browser opens https://your.customdomain.com, which resolves via DNS to proxy.reslug.link (the customer's CNAME target). The request lands on Caddy, running on a Hetzner VPS. Caddy sees a TLS connection for a hostname it does not yet have a certificate for, calls the API to ask whether this hostname is registered, gets a 200, issues the cert via Let's Encrypt, caches it, and forwards the now-decrypted HTTPS request to the ASP.NET Core Web API (the cube on the right).<br>The bottom row is the response path, right to left. The API runs the request through its middleware (where the Host header is resolved to a workspace) and into the matching controller, which produces a response. That response goes back through Caddy, the same TLS terminator that handled the request, and out to the customer's browser. Caddy is on both rows because it sits on the request and response path; it is the only thing that ever holds the TLS session for the custom domain.<br>Two consequences worth being explicit about. First, the API never serves TLS directly for custom domains: Caddy does, which is why the cert-issuance and renewal logic lives entirely in the proxy layer. Second, the API's job shrinks to two things: tell Caddy which hostnames are valid (the ask endpoint), and resolve the Host header on incoming requests to find the right workspace (the middleware). Subsequent requests for the same hostname skip the ask call until the cert renews, so the ask endpoint is cold-path, not hot-path.<br>Why Caddy on-demand TLS<br>Three reasons it wins for this use case.<br>First, on-demand TLS is a first-class Caddy feature, not a plugin. You toggle it in the Caddyfile and Caddy handles ACME, storage, renewal, and rate-limit backoff.<br>Second, the ask directive is the right abstraction. Caddy will not issue a cert for a hostname unless your backend says yes. That single hook prevents abuse (someone pointing a million domains at your proxy) and keeps authorization where your business logic lives.<br>Third, the cost. A Hetzner CX22 runs about 5 EUR a month and will comfortably handle thousands of custom domains. You only pay more when you want HA, which you can postpone until it actually matters.<br>Step 1: The Caddyfile<br>Here is the full Caddyfile running on proxy.reslug.link:<br>email ops@reslug.com
on_demand_tls {<br>ask https://api.reslug.com/api/internal/caddy/ask?secret={env.CADDY_ASK_SECRET}<br>interval 2m<br>burst 5
# Catch-all for any custom domain pointed at this proxy<br>:443 {<br>tls {<br>on_demand
reverse_proxy https://api.reslug.com {<br>header_up Host {http.request.host}<br>header_up X-Forwarded-Host {http.request.host}<br>header_up X-Forwarded-Proto {http.request.scheme}<br>header_up X-Forwarded-For {http.request.remote.host}
# Health endpoint for the VPS itself<br>proxy.reslug.link {<br>respond /healthz "ok" 200<br>A few things to call out.<br>The global on_demand_tls block holds the ask URL and the issuance rate limits.interval 2m and burst 5 gate certificate issuance attempts, not ask calls: Caddy will allow at most 5 new cert obtains in any 2 minute window across the whole server. Why that matters: Let's Encrypt has its own per-account and per-hostname rate limits, and exhausting them can lock you out for hours. The burst/interval knobs are your local circuit breaker so a flood of random unknown hostnames (which Caddy would otherwise try to issue certs for, one per handshake) cannot run you into Let's Encrypt's ceiling.<br>The ask endpoint itself is hit on every fresh handshake for a hostname Caddy has no cert for: that is what authorizes the obtain in the first place. Most rejections (NotFound) never make it as far as the issuance step, so the rate limits mostly protect you against the small fraction that pass the ask check. If your ask endpoint is on a separate, cheap path (it should be),...