Before a single container runs, get the naming layer right. Everything in this series hangs off DNS, and the two habits in this post (variables instead of literals, scoped tokens) are the ones you’d least enjoy retrofitting. I’ve renamed this lab’s domains twice. The first rename took a month of whack-a-mole. The second took an afternoon, because of these rules. Let me save you the month.

What you’ll need

  • One domain you consider “canonical” (your main personal domain)
  • Optionally a couple more for public-facing and media services
  • A DNS provider with an API and scoped tokens (I use Cloudflare; anything comparable works)

Step 1: give each domain a job

Role Example What lives there
Canonical example.dev SSO, vault, admin tools, mail
Public example.cool things you share with friends
Media example.tv the request portal
Internal *.<host>.zt.example.dev every service, on every host

The canonical choice matters most. Your SSO will live there, and forward-auth session cookies are scoped to a registrable domain: a session on auth.example.dev covers *.example.dev and can never cover example.cool. Keep everything sensitive under the canonical roof and the SSO story (post 8) stays one cookie simple. Scatter admin tools across domains and you’ll log in all day, or worse, “temporarily” skip auth on the strays.

Step 2: never type a domain into a config file

Every compose file and proxy label references variables, never strings:

labels:
  - traefik.http.routers.vault.rule=Host(`vault.${INTERNAL_DOMAIN}`)
  # never: Host(`vault.hermes.zt.example.dev`)

The values live in one secrets file per host (post 4):

PRIMARY_DOMAIN=example.dev
MEDIA_DOMAIN=example.tv
INTERNAL_DOMAIN=hermes.zt.example.dev   # per host

This looks like pedantry right up until you change domains. My afternoon rename was: swap the variable values, redeploy, fix the handful of apps that store their own URL internally. The month-long rename was a find-and-replace across a hundred files on three hosts, with regressions surfacing for weeks. Adopt the variable rule on day one. It costs nothing.

Step 3: two wildcards, then stop touching DNS

Public records. The apex and a wildcard, both pointing at the VPS:

example.dev.      A      203.0.113.10   ; url, the edge VPS
*.example.dev.    CNAME  example.dev.

That wildcard means every future public service is just a new route on the proxy. I have not created a per-service public DNS record in years.

Warning: the apex points at the VPS. Always. At some point you’ll be tempted to point one busy hostname straight at your home connection to skip the hop. Don’t. Home IPs change, get CGNAT’d, and when your home line drops it takes that name down with it. The performance itch gets scratched properly in post 13, with smarter DNS instead of riskier records.

Internal records. One wildcard per host, answering with overlay IPs (the overlay network is post 5; for now read it as “private IPs that work from anywhere”):

*.hermes.zt.example.dev.   A   10.99.0.10
*.bender.zt.example.dev.   A   10.99.0.20
*.url.zt.example.dev.      A   10.99.0.30

Yes, these are public DNS records with private answers, and that’s fine. RFC1918 addresses tell an attacker nothing useful without overlay membership, and in exchange every laptop and phone gets correct answers with no internal DNS server to depend on. We add local DNS much later (post 13) as an optimization. Bootstrapping your lab on DNS that lives inside the lab is a circular dependency you’d meet at the worst time.

Check it worked:

dig +short vault.example.dev          # the VPS address
dig +short app.hermes.zt.example.dev  # 10.99.0.10

Step 4: scoped tokens, one per consumer

Several things will need DNS API access: the proxies do DNS-01 challenges for wildcard certificates (post 6), and a ddns updater tracks site B’s drifting IP. The lazy move is one all-powerful API key pasted everywhere. I did that for years and nothing bad happened, which taught me nothing. Domains are the root of trust for email, certificates, and OAuth callbacks; a leaked global key means someone can re-point your MX and read your password resets.

Mint one token per consumer class:

Token Held by Can touch
INTERNAL proxies on internal hosts DNS edit, ONE low-value zone
EDGE url’s proxy DNS edit, the public zones
DDNS the ddns updater DNS edit, its one zone

The reasoning: internal hosts are numerous, and the most likely place for a token to leak into a backup, a log, or a compromised container. So their token is the least useful one to steal. The edge genuinely needs broader cert access, but it’s one hardened machine and its token revokes without touching anything else.

When a token does leak (mine went into a paste once, attached to a debug log), revocation is one click and one consumer to re-credential. Not a rotation across everything you own.

Proxied vs DNS-only

Everything above is DNS-only (grey cloud, in Cloudflare terms). The edge terminates its own TLS, Crowdsec handles the bouncing (post 7), and half the lab’s traffic isn’t HTTP anyway: mail and SSH don’t survive an HTTP CDN proxy. There’s a fair case for proxying public HTTP services (origin hiding, DDoS absorption). My advice: start plain, where dig tells the truth while you debug, and add proxying when you have the problem it solves.

That’s the naming layer done. Two wildcards, four variables, three tokens, and you shouldn’t need this provider’s dashboard again for months.

Next: the repository all of this lives in, and the infra/app split that protects you from your own automation.