Assembyl is a multi-tenant application I work on. Four services out of one monorepo: an API, an admin interface, a tenant-facing web app, and a public landing page. Until recently the staging environment for all of that ran on managed cloud, with a hosted CI service doing build and test on every push, then building containers and deploying them to a serverless runtime, against a paid database tier.
It worked fine. It also billed every month for an environment nobody touches outside weekday afternoons, while I had a Proxmox cluster in my house with a self-hosted platform layer already running on it, next to my file storage and my git server. The hardware was bought. The electricity was already being spent. Staging was paying rent somewhere else for no reason I could defend.
The complication was that staging had to stay multi-tenant. Every tenant gets their own subdomain. So the job was never "deploy an app." It was "deploy a routing pattern that survives adding the twentieth tenant without twenty pieces of manual work."
What I tried first, and why it failed
The natural structure is the one everybody reaches for:
{tenant}.staging.example.comOne application, one wildcard domain *.staging.example.com, one mental model. I set up the whole path for it: internal DNS, a catch-all route on the reverse proxy, the tunnel configuration, the certificate request. All of it landed cleanly.
Then I tested from outside the network and got a TLS handshake failure.
*.staging.example.com is a two-level-deep wildcard, because the zone apex is example.com. Cloudflare's free Universal SSL covers the apex and one level below it. It does not cover a wildcard two labels deep. That needs Advanced Certificate Manager, which is a paid add-on at roughly ten dollars a month.
I was doing this migration specifically to stop paying a monthly bill. Trading one subscription for a smaller subscription is not a migration, it is a discount. So that was out.
The other option I considered seriously was dropping public access entirely and serving staging over a private network overlay, with the certificate issued through a DNS challenge and no public ingress at all. That works, and it costs nothing. It also means every person who wants to look at staging has to install a VPN client first. For an engineering-only environment that is a fine trade. For something where the point is that a colleague or a prospective customer can open a link in whatever browser they have, it breaks the use case entirely.
The fix was to flatten the namespace
Keep the wildcard one level deep by moving the tenant name into the label rather than in front of it:
{tenant}-as.example.comThe -as suffix acts as the namespace. Same *.example.com certificate that Universal SSL already issues for free, covering every tenant that will ever exist. No paid tier, no VPN requirement, no per-tenant certificate.
Two concessions came with it, and both are worth stating plainly rather than glossing.
Each tenant needs its own DNS record. Wildcard DNS records have to have the wildcard at the start of a label, so *-as.example.com is not a thing you can create. That is a per-tenant API call, which is automatable, but it is not zero.
And tenant names become visible in DNS. Anyone enumerating the zone's records can see who the tenants are. For a staging environment full of test accounts that is acceptable. For production it would not be, and production would need a different pattern.
In exchange: one certificate, one routing rule, and adding a tenant never touches the proxy configuration at all.
One routing rule for every tenant
http:
routers:
tenants:
rule: "HostRegexp(`^[a-z0-9][a-z0-9-]*-as\\.example\\.com$`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
domains:
- main: "*.example.com"
service: tenants-svc
middlewares:
- security-headers@fileservices: tenants-svc: loadBalancer: servers: - url: "http://192.0.2.80:80" passHostHeader: true ```
Every tenant name matches that regex. Every request lands on the platform proxy, which routes by Host header to the right container. passHostHeader is the load-bearing line: drop it and every tenant arrives at the application looking like the same host, which in a multi-tenant app is a genuinely bad afternoon.
Warm the certificate before anyone arrives
Issuing a wildcard certificate through a DNS challenge takes about thirty seconds. If the first request that triggers issuance is a real user's, that user gets thirty seconds of nothing, or a timeout. So trigger it yourself with a request for a name that does not exist yet:
curl -sk -H "Host: prewarm-as.example.com" \
https://192.0.2.10/ -o /dev/null -w "HTTP %{http_code}\n"That matches the regex, which triggers the certificate request, which issues and caches the wildcard. Every subsequent tenant deploy has zero wait. It costs one throwaway request and it is the difference between a launch that looks instant and one that looks broken.
What the migration actually looked like
Four applications on the self-hosted platform layer, each pointing at a different directory of the same monorepo, each with its own domain and its own build. All four went from "create application" to serving in about fifty minutes. The CI moved to a self-hosted runner on the same cluster, which had already replaced the hosted CI service earlier that week.
New recurring cost for the staging environment: nothing. The hardware was already there, already powered, already backed up nightly as part of everything else.
Why this holds up
The free tier of a CDN is genuinely the right scale for a staging environment. Tunnelled ingress plus free certificates handles public access with no port forwarding and no exposed origin. The one-level wildcard limit is only a problem if you insist on fighting it. Flatten the namespace and it stops existing.
A regex-matched catch-all route scales to any number of tenants in one block of configuration. The proxy is the piece you least want to be editing under time pressure, and this pattern means you never edit it after the first time.
Running the platform layer in its own container on the cluster keeps it from becoming a shared fate with everything else. It gets its own backup unit, its own resource ceiling, its own restart. When it needs attention, nothing else is affected.
What I would do differently
Use the flat pattern from the beginning instead of arriving at it after ninety minutes of debugging a TLS failure. The certificate limitation is documented; I just did not go looking until something broke.
Wire the deploy webhook before the first deploy rather than after. I spent the first stretch clicking Deploy by hand, which is fine once and irritating by the fifth time.
Add health check endpoints to the services before deploying rather than afterwards. Two of the four apps sat in an unknown health state for days purely because nothing was answering a probe, and an unknown state is worse than a red one because it is easy to ignore.
The one thing I deliberately have not moved is the production database, which is still on a managed tier. The homelab is not on battery backup yet, and a primary database is the wrong place to find out how your house handles a power cut. Staging can afford to learn that lesson. Production cannot.
A note on the addresses
Domains and addresses in this post are documentation placeholders on reserved ranges (RFC 5737 and RFC 2606). The architecture is exactly what runs; the specific names are stand-ins.
Resources
- Coolify documentation - the self-hosted platform layer
- Traefik HostRegexp matcher - the catch-all rule
- Cloudflare Tunnel - ingress with no open ports
- Let's Encrypt rate limits - pre-warming once is well inside them
- More homelab write-ups: iamkay.eu/blog

