Oliver's blog

SSL for customer subdomains

16 Aug, 2026

If your product gives every customer their own subdomain (shopname.yourdomain.com), you eventually have to answer one question: how do you serve HTTPS for a domain that doesn't exist yet?

My classifieds platform gives every customer a subdomain like carsforsale.on-yclas.com the moment they pick a name. The site is live right away, so the certificate has to be valid right away too. And you can't pre-issue certificates for thousands of names nobody has chosen yet.

The obvious answer is a wildcard certificate. One certificate, every subdomain covered. Cloudflare was my first stop. My first test, with the wildcard record left unproxied, gave visitors a certificate warning. Enabling the proxy fixed it: every customer subdomain gets HTTPS served from Cloudflare's edge, on the free plan.

That works, but only on Cloudflare: all customer traffic flows through their proxy, and the DNS zone stays in their account. The proxy is the part that's hard to undo. (My zone actually lives on Cloudflare, DNS-only, no proxy. That version is easy to leave.) I want the freedom to move DNS providers, so I kept looking.

The next option was a wildcard from Let's Encrypt directly. That works too, but it means the DNS-01 challenge: programmatically creating and deleting TXT records at the DNS provider. You need API credentials on the server, and another integration to maintain.

The route I took instead needs one wildcard DNS record pointing at the server and nothing else. It's Caddy with on-demand TLS. A single site block serves every subdomain, and certificates are fetched on first request: when someone visits a subdomain with no certificate yet, Caddy gets one right there. No per-subdomain DNS records, no per-signup API calls.

And it doesn't stop at my own domain. If a customer wants to use their own domain instead, they point it at the server and Caddy handles it the same way.

The trade-off: every new subdomain gets its own certificate, and Let's Encrypt caps new certificates at 50 per domain per week. Renewals are exempt, so the limit is on new signups per week, not on how many customers I can host. My platform is fresh and nowhere near 50 signups a week, so simple wins for now.

But on-demand TLS alone has a catch. Without an extra check, Caddy will issue a certificate for any domain pointed at your server. Anyone could point a phishing domain at you and collect a valid certificate for it. So before issuing anything, Caddy first asks an endpoint on my app whether that domain should exist.

How it works

Caddy was already my web server, running the whole app, so this was a few lines added to a config I already had. I can't tell you whether nginx can do this. Caddy just makes this kind of setup easy.

The interesting parts:

{
	on_demand_tls {
		ask https://app.yclas-neo.com/api/caddy/ask
	}
}

:443 {
	tls {
		on_demand
	}

	root * /home/eddy/app.yclas-neo.com/current/public

	php_fastcgi unix//run/php/php8.5-fpm.sock
	file_server
}

(I trimmed a few things: headers, encoding, log rotation, and the site block for app.yclas-neo.com itself. That's the app; on-yclas.com is the domain customers get subdomains on. The app's own certificate comes from its own site block, so there's no chicken-and-egg with the ask endpoint.)

The :443 site block is the interesting one: it's a catch-all. Any hostname that resolves to my server lands here, whatever the domain, and my app reads the Host header to serve whichever site owns it. That's what makes custom domains work later. (The global block says where to ask; the site block turns on-demand issuance on.)

Caddy doesn't care what answers the ask URL. The contract: the domain arrives as a ?domain= query parameter, a 2xx response means yes, issue a certificate, anything else means no. Node, Go, Rails, a shell script, whatever you run. Mine is a Laravel controller:

<?php

namespace App\Http\Controllers;

use App\Models\Site;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class CaddyController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $domain = trim(strtolower((string) $request->query('domain')));

        if ($domain === '') {
            return response()->noContent(Response::HTTP_BAD_REQUEST);
        }

        $apex = preg_replace('/^www\./', '', $domain);

        $exists = Site::query()
            ->where('domain', $domain)
            ->orWhereIn('custom_domain', [$domain, $apex])
            ->exists();

        return response()->noContent($exists ? Response::HTTP_OK : Response::HTTP_NOT_FOUND);
    }
}

No row in the database, no certificate.

(The www-stripping in the code is for custom domains: www.shop.trucksforsale.com points at the same server as shop.trucksforsale.com, so it deserves a certificate too.)

Two things I decided not to do: no rate limiting, and it's open to the internet. Anyone can probe it with domain guesses and use the difference between 200 and 404 to discover which subdomains exist. I know, and I see it as harmless for my case.

On my side, a signup creates a row in a database. That's it. No DNS record, no API call, no certificate command, nothing scheduled.

On the customer's side, they pick a name, the site is created, and they land in the admin panel with a link to their public site. They are usually the first person to visit their subdomain. That first visit takes a couple of seconds, because that's the moment the certificate is issued. Every request after that is fast.

I'm fine with that trade. Seems fair.

Custom domains

This is the part that sold me on the approach, and it costs nothing extra.

A customer who wants their own domain points a CNAME at cname.on-yclas.com: shop.trucksforsale.com → cname.on-yclas.com. That hostname shares the same A record as everything else: my server.

On my side, their domain goes into custom_domain. The next visit to shop.trucksforsale.com hits the catch-all, Caddy asks my endpoint, the endpoint finds the domain and returns 200, and Caddy issues a certificate for a domain I don't own. The customer proved the domain is theirs the only way that matters: they pointed its DNS at my server.

No wildcard can do this. A wildcard covers exactly one domain, mine. On-demand TLS covers any domain my endpoint approves.

Bare domains are the one catch: a CNAME at the apex only works on DNS providers that support it, like Cloudflare. I tell customers about that when they ask for their bare domain. Until their DNS propagates, their domain returns an error, and their branded subdomain keeps working in the meantime.

Copy this setup

  1. Build the ask endpoint first. It takes ?domain= and returns 2xx for domains you allow, 404 for everything else. About a dozen lines in any framework. It has to be live before on-demand TLS, because Caddy won't issue anything the endpoint hasn't approved.
  2. Point a wildcard DNS record *.yourdomain.com at your server's IP. For custom domains later, add a hostname like cname.yourdomain.com on the same IP for customers to CNAME to.
  3. Add to your Caddyfile the global on_demand_tls { ask ... } block and a site block serving your app with tls { on_demand }.
  4. caddy reload. Zero downtime, no certificate files, nothing to pre-generate.
  5. Test with a hostname that doesn't exist yet.
  6. When a customer leaves, delete the row. The endpoint starts returning 404, and Caddy stops renewing the certificate.

The trade-offs

First visit delay. A couple of seconds on a cold subdomain, then fast forever. The person waiting is the customer who just signed up, not a stranger.

Renewals. Automatic.

Rate limits. My platform signs up 0 to 3 sites a day, so the 50-per-week cap is theoretical. It only counts against on-yclas.com anyway: every customer's own domain brings its own weekly budget. I've never hit it, and honestly I don't know what a customer would see if it happened. If I ever get close, the fix is boring: switch to one wildcard certificate via DNS-01 and keep everything else. By then Cloudflare's proxied wildcard is also an option again. That's a decision for the version of me that has the problem.

Churn. Nobody has churned yet, so I can't tell you what a visitor sees once a deleted site's certificate stops renewing.

The solution should match your actual scale, not the scale you imagine. At a few signups a day, one wildcard record, a catch-all server block, and an endpoint that knows my customers beat every enterprise version of this problem.

Caddy's on-demand TLS docs cover the ask endpoint contract in detail.