Custom domains
An application is always served at its name under every platform domain. A custom domain is a host of your own that answers from it as well: claimed once for the whole installation, by an operator, and picked up by every ingress on its own.
#What a claim is
Hosts under a platform domain are rendered from application names and from nothing else — shop is served at shop.apps.example.com, and no setting changes that. A custom domain works the other way round: you bring a host, you say which application answers on it, and the installation starts serving it there too.
A claim is two writes. The claim record at _scafo/domains/<host>.json is what makes the host that application's, for the whole installation. The mirror in the application's own domains array is what the routing is rendered from, because that record is one every node already reads on every reconcile pass — so a claimed host costs no extra reads on the path that serves it.
The application is checked first, then the claim is created, then the application lists the host. If something dies in between, the result is a claim nothing routes to: running add again finishes it. Re-claiming a host the same application already holds is idempotent, so add is always safe to run again.
Nothing about the platform host changes. An application keeps answering at <name>.<platform domain> whatever else it holds, and a custom domain can never shadow one.
#scafo app domain
scafo app domain add shop.example --name shop
scafo app domain remove shop.example --name shop
scafo app domain list --name shop| Command | Effect |
|---|---|
add <host> | Claim the host for the application, and report whether DNS already points here. |
remove <host> | Drop a host this application holds. Every ingress stops answering for it within one reconcile interval. |
list | The hosts claimed for the application, sorted, each marked when it covers its subdomains. |
The host is a positional argument. All three name their application with --name, and optionally --env, exactly as the other scafo app subcommands do. All three take the remote flags (--config, --remote, --node, --key) and the bucket flags: with a node to ask they go through it on the authority of an operator key, and with none they write the registry directly.
--json prints what was done instead of the summary line:
| Command | JSON |
|---|---|
add | application, host, platformHosts, dns, and wildcardDns when the claim covers its subdomains |
remove | application, host, platformHosts |
list | application, domains, wildcardDomains |
host is the host as the registry holds it, and platformHosts is where the application answers under this installation's own domains — which is what a custom domain is pointed at.
A claimed host also shows up in scafo app show, in the domains line.
#Who may claim one
Both routes stand behind an operator key whose application scope reaches the application. A workload OIDC token — the one a CI pipeline deploys with — never reaches them, whatever the application's trust policy admits. A pipeline publishes an application; it does not decide what the fleet answers on, and a repository that can deploy should not be able to move a company's domain onto its own preview build.
#One host, one claim, installation-wide
The first claim wins. A host another application holds is refused with reference-held (409), and add changes nothing.
The refusal names the holder only to a caller whose own scope reaches that application. To anyone else it says another application of this installation: an operator scoped to one tenant must not be able to read off which of a fleet's applications holds a host by asking for it one host at a time.
Releasing is a statement about the application you named. A host held by somebody else is not-found, in the same words as a host nobody claimed at all — so a release can never take another application's claim, nor reveal by the shape of its refusal that there is one to take.
#Which hosts can be claimed
Lowercasing is the only thing a claim repairs: DNS is case-insensitive, so Shop.Example and shop.example are one name and the registry keeps one spelling. Everything else is refused with invalid-input naming the rule that failed, rather than quietly fixed — somebody who pasted a URL needs to read which part of it is not a host.
| Refused | Because |
|---|---|
https://shop.example, shop.example/path | A host carries no scheme and no path |
*.shop.example | A host carries no wildcard. Subdomains are asked for with --wildcard on the base |
shop.example:8443, an IPv6 literal | A host carries no port |
shop.example. | A host carries no trailing dot |
example | It must be fully qualified: at least two dot-separated labels |
shop..example | It holds an empty label |
shop_1.example, -shop.example, shop-.example | A label holds letters, digits and inner hyphens and nothing else |
203.0.113.10 | Its last label is all digits, so it names an address rather than a host |
| Over 253 characters, or a label over 63 | Past what DNS allows |
Punycode labels (xn--…) are ordinary labels and pass.
A host equal to or under one of this installation's platform domains is refused too, with the domain named. Every host there is rendered from an application's name, so a claim on one could only ever try to shadow a host the fleet already answers on. Reach the application at <name>.<platform domain> instead.
One application holds at most 64 custom domains. Past that, add is invalid-input and says to release one first. The bound is on the record rather than on the fleet: every host lands in the application's own document, which every node reads on every pass and every ingress reads the routing rendered from, so an application holding a thousand of them would be a cost the whole installation pays on a loop.
#Blocked and retired applications
| State | add | remove |
|---|---|---|
| Blocked | Refused with application-blocked — a blocked application is served nowhere, so the host would route to nothing | Allowed |
| Retired | Allowed | Allowed |
Releasing stays open in both cases on purpose: a host is the operator's to take back whatever state the application is in, so nothing is ever trapped.
#No proof of ownership
Scafo asks for no proof that you own the host, and gates nothing on DNS. One installation is one trust domain: everyone who can claim a host already holds an operator key the fleet trusts, and the first claim of a host excludes everybody else. Beyond that, a name that does not point at the installation is served to nobody and gets no certificate, so a claim on a domain you do not control buys nothing.
What add does do is tell you whether the name points here yet.
#What the claim says about DNS
Once the claim is written — never before, and never as a condition of it — the CLI resolves the host and the application's platform hosts, and compares the addresses they answer with. Addresses rather than records: a CNAME, an ALIAS and a copied A record are three ways of giving the same answer.
| Answer | Printed as |
|---|---|
| The host already resolves where the application answers | an acknowledgement on stdout |
| It resolves somewhere else, or the resolver said nothing usable | a warning on stderr, scafo: warning: … |
scafo: "shop.example" is claimed for "shop" through https://apps.example.com; every ingress of this installation picks it up within one reconcile interval, because nothing is pushed into one
scafo: warning: "shop.example" does not resolve to this installation yet: point it here, for instance with a CNAME to "shop.apps.example.com", and the certificate is issued on the first request that arrives after it doesThe suggested target is the application's own platform host. An installation that serves no platform domain has nothing to compare against, so the answer is always the warning, without a target to name.
Note
The check never refuses anything and never changes the outcome: the claim is already written, the command exits 0 either way, and no answer it can get is a failure. --json carries the same thing as dns: "ok" | "pending" | "unknown". SCAFO_SILENCE_WARNINGS=1 silences it along with every other CLI warning.
#Claiming the subdomains too
scafo app domain add shop.example --name shop --wildcard--wildcard claims the single label below the host along with the host itself. Nobody ever types a *: the flag goes on the base, it is stored as a flag on that base's claim, and the * spelling exists only inside the document an ingress reads, where a key has to name a set rather than a host.
Three rules, weighed in this order, and nothing below them:
| Request host | Answered by |
|---|---|
shop.example — the base | shop |
<env>.shop.example | shop-<env>, where this installation has that application and something is serving it |
| any other single-label subdomain | shop itself |
a.b.shop.example — two labels or more | nothing |
The second rule is the same mapping --env gives on the command line: scafo app show --name shop --env preview and preview.shop.example name one application, shop-preview, which is already served at shop-preview.<platform domain>. An environment the installation does not have, or one nothing is currently serving, falls through to the third rule and is answered by shop.
A wildcard covers exactly one label, the way a TLS wildcard does. a.b.shop.example splits into a and b.shop.example, which is a different base and matches only a claim on that name.
#Exact claims win
A host claimed exactly is always answered by whoever claimed it, ahead of both wildcard rules. That is what makes a wildcard safe to hand out: it never takes a name that somebody else was given.
#Who may claim under a wildcard base
A base claimed with its subdomains already answers for every name one label under it, so an exact claim there would carve a name out of its holder's hands. Only the holder and its own <holder>-… family may do it; anyone else is refused with reference-held, naming the base.
The mirror image is refused too: --wildcard on a base where another application already holds an exact host one label under it. The refusal says to claim the base without its subdomains, or to have that host released first.
#The extra scope a wildcard claim needs
Claiming a base with its subdomains also decides what <name>-<env> answers on, so it takes a key that reaches those as well: one whose scope covers shop-*, not only shop. A key that does not is refused with auth-scope, and the refusal says to ask an admin for a key scoped to shop-* or to claim the host on its own.
#Upgrading, and never downgrading
| Run | Result |
|---|---|
add --wildcard on a host the same application already holds exactly | Upgraded to cover its subdomains |
add without the flag on a host that already covers them | Unchanged — a plain re-run never takes the subdomains away |
remove | Drops the whole claim, base and subdomains together |
A silent downgrade would take every subdomain out of routing without anybody asking for it, which is why re-running the plain command cannot cause one.
A claim that covers its subdomains is marked wherever a host is printed:
scafo: "shop" holds 2 custom domains
shop.example (+subdomains)
www.shop.co--json carries the same thing as wildcardDomains, which is always a subset of domains.
With --wildcard, add asks DNS a second question and reports it as wildcardDns, in the same three answers and with the same warning. It asks under scafo-dns-check.<base>, a name nothing is ever deployed at: what it wants to know is whether the wildcard record exists, and if that name resolves here then every other subdomain does too.
#Certificates
Important
Scafo approves on-demand certificate issuance for the claimed host itself, and for a subdomain that names an existing application — and never for an arbitrary subdomain. This is a deliberate limit, not an oversight.
The routing document names exactly the hosts that may get a certificate, and the ingress decides from the document it already polls: Caddy approves an on-demand certificate only for a name the routing names exactly, and Traefik obtains what the rendering asks for on each router. The *.<base> entry of a claim that covers its subdomains is there for routing, not for issuance, and no name is approved on its strength. The document names:
| Host | When |
|---|---|
| A bare platform apex | The fleet API is published there |
<name>.<platform domain> | That application is serving: a live node runs a ready process for it, which a retired or blocked application never has |
| A claimed host | The application that claimed it is serving |
<env>.<base> under a claimed base that covers its subdomains | <holder>-<env> is an application of this installation, and it is serving |
Nothing else is approved.
Why. On-demand issuance is driven over the open internet, by whoever sends the first request. If a stranger could make the fleet obtain a certificate for any name at all under a claimed domain, a stream of random names would exhaust the certificate authority's weekly issuance limit for that domain — and then no certificate could be obtained for it at all, including the ones the domain actually needs.
The consequence, plainly: the catch-all subdomains are routed, and are served over HTTPS only where the ingress already holds a certificate covering *.<base>. add --wildcard says so on the terminal:
scafo: warning: a subdomain of "shop.example" that is not an application of this installation is served over HTTPS only where the ingress already holds a certificate covering "*.shop.example": scafo approves on-demand issuance for "shop.example" and for a subdomain naming an application, and never for an arbitrary one, so obtain the wildcard certificate through DNS-01 at the ingressObtaining that certificate is the ingress's own configuration, and scafo never writes it.
#What the ingress sees
The routing document lists one entry per exact host — platform hosts, then claimed hosts, the base of a wildcard claim among them — plus one *.<base> entry per claim that covers its subdomains.
A consumer looks a request's host up exactly first and, failing that, replaces the host's first label with * and looks that up once. Two labels below the base match nothing. An ingress that knows nothing of the wildcard key never matches it and answers as it would for a host the fleet does not serve, which keeps the document's version unchanged and the addition purely additive.
Only hosts something is currently serving are published: a host with no live process behind it is left out, and under a wildcard base it then falls to the *.<base> entry — which is the holder's — rather than disappearing.
Traefik's rendering writes the *.<base> entry as a HostRegexp router matching exactly one label under the base, at a priority below every exact Host router. Traefik's own default priority is the length of the rule, which would put a long pattern ahead of a short exact host — precisely backwards for a fallback — so the priority is stated rather than left to it.