On this page
Web hosting: hostnames and DNS
The hostname and DNS model
One web product owns one hostname. The hostname's zone is whatever the
registrar says it is, and Stado does not guess: stado web declare records the
hostname, and stado web route resolves its zone, writes the record through
the edge the product declares, and reports what the record became.
For the Stado edge, stado web route does three things in an order that is
forced rather than chosen.
First the hostname is reconciled into the edge proxy's configuration, which is
rendered whole from the product declarations, so a second route for the same
product changes nothing. Then the A record is written to the edge's public
address by stado dns set — a whole-zone read, a merge of one name, a
whole-zone write. Only then can the certificate exist: Let's Encrypt delivers
its HTTP-01 or TLS-ALPN-01 challenge to whatever the hostname resolves to, so
Caddy cannot obtain a certificate for a name that does not yet point at it.
That is why the third step is the one that decides the verdict. The command
polls https://<hostname> until it answers over TLS with a 2xx and no
x-vercel-id header, and reports how long that took. Between the record
moving and the certificate existing there is a real window in which the name
resolves to an edge that cannot complete a handshake; success is never
reported on anything less than a completed TLS request, and a hostname that
still answers from Vercel is reported as unpublished with the server and
x-vercel-id values actually observed.
The site block existing before the record moves is what keeps that window short: the first request to arrive after the cutover finds a proxy that knows the name and can begin an issuance, rather than one that has never heard of it.
For a zone at Cloudflare the record is written by the Cloudflare API as a
proxied CNAME to the tunnel, and the tunnel's ingress rule is written in the
same command.
Nothing removes a Vercel project. A hostname stops being served by Vercel when
its DNS record stops pointing there, and that record is the last step of
stado web route.
A redirect is a product with no unit
Five of the fleet's Vercel projects are one rewrite each: aiwisent.com,
getwisent.com, trywisent.com, wisentai.com and wisentplatform.com all
answer https://wisent-app.com/:path* and nothing else. Their repositories
carry a vercel.json with a single redirect, no framework and no build. There
is no application to host, and expressing one as a web product with a port and
a consumer would mean declaring a unit nobody runs.
So a product may declare a hostname and a target instead:
stado web declare aiwisent-com \
--hostname aiwisent.com \
--redirect-to https://wisent-app.com
stado web route aiwisent-com
--host, --port, --consumer, --readyz, --env, --secret and
--database are refused beside --redirect-to, and the configuration parser
refuses the same combination: a declaration carrying both says two different
things about what the product is. The edge renders a redir block rather than
a reverse_proxy, and the rest of the path is unchanged — the hostname is
reconciled into the edge, the record is written by stado dns, and the
certificate is ordered for it like any other:
aiwisent.com {
redir https://wisent-app.com{uri} 308
}
{uri} carries the path and the query across, which is exactly what the
Vercel rewrite's /:path* did. The status is 308 rather than 301 because 308
preserves the method and the body: a POST to the old hostname arrives at the
new one as a POST, where 301 lets a client turn it into a GET and a form
submission silently becomes a page load. Permanent either way — these
hostnames are not coming back.
The target must be https:// with a public host name, no query, no fragment
and no trailing slash. A redirect Stado publishes on its own edge must not
send a browser to a hostname it holds no certificate for, a query would
collide with the appended {uri}, and a trailing slash would make every
redirected path a double slash. A path prefix is allowed:
--redirect-to https://wisent-app.com/pricing.
stado web deploy refuses a redirect and says which command publishes it;
stado web status reports kind: redirect, its target, the selected edge,
DNS observations and a verdict instead of asking about a unit that does not
exist. A missing selected Stado edge is edge-unconfigured, with its cause
in edge_error. stado web remove retracts the hostname and reports no-unit.
Every product answers under wisent.com and wisent.ai
A product that answers under wisent.com answers identically under
wisent.ai, and the other way round. The second name is a redirect product
named <product>-<hostname with dots as dashes>:
stado web declare tama-landing-tama-wisent-ai \
--hostname tama.wisent.ai \
--redirect-to https://tama.wisent.com
stado web route tama-landing-tama-wisent-ai
stado web list checks this for every declared hostname. In --json each row
under either zone carries twin (the same name in the other zone) and
twin_declared; the text listing ends with one
missing twin: <hostname> has no declaration for <twin> line per hostname
whose counterpart nothing declares.
A hostname in front of an existing service
A hostname can belong to something that is not a web product: a Rust binary
with its own managed unit, answering on a loopback port on its host, published
over public HTTPS by a hosting provider whose whole content is a catch-all
rewrite to the host's tailnet name. Such a provider contributes exactly one
thing: a certificate for a wisent.com name. That is the thing this edge
exists to do.
So a product may declare a hostname in front of a service the registry already runs:
stado web declare brama --hostname brama.wisent.com --upstream-service brama
stado web route brama
No --host and no --port. The service directory already records which host
the service is active on and which address it answers, and a copy of either in
this declaration would point at the old host the day the service moved — so
the upstream is resolved out of the directory on every render, not snapshotted
when the command was typed. --host, --port, --consumer, --readyz,
--env, --secret and --database are refused beside it, and so is
--redirect-to: a hostname either answers with a redirect or forwards to a
service, never both.
The rendered site block is an ordinary reverse_proxy at the service's own
address on its active host, reached over the tailnet:
brama.wisent.com {
reverse_proxy http://<active host>:<service port>
}
Nothing here deploys, installs or stops that service. stado web deploy
refuses and names stado service status and stado service deploy as what
owns the unit; stado web status reports kind: upstream-service with the
service's host, state and last beacon time read out of the same fleet-wide
join every other row uses; stado web remove retracts the hostname and
reports no-unit, leaving the service running. A stado web command
stopping Brama because someone removed a hostname in front of it would be
this plane reaching into a service it does not own.
A path under another product's hostname
One hostname can be two things: its catch-all belongs to the service
declared above, while /docs is a set of documentation pages versioned with
the product that the service does not serve itself. The hostname then needs
two answers.
A mount is an ordinary unit product — built, released and deployed like any other — whose hostname belongs to a different declaration:
stado web declare brama-docs \
--hostname brama.wisent.com \
--path-prefix /docs \
--host <target> \
--port 3220 \
--consumer brama-docs-web
stado release submit brama --channel stable
stado web deploy brama-docs
stado web route brama-docs
The edge renders it inside the owner's site block, ahead of whatever answers the rest:
brama.wisent.com {
handle_path /docs* {
reverse_proxy http://<target>:3220
}
reverse_proxy http://<active host>:<service port>
}
handle_path, not handle: it strips the matched prefix before proxying, so
/docs/core arrives at the unit as /core — which is the unit's own path,
because the unit is a site whose pages start at /. handle would forward
/docs/core unchanged and every page would answer 404. The matcher is
<prefix>* so the prefix itself matches too, and the order inside the block
is the semantics: Caddy takes the first matching route, so a catch-all
rendered before the mount would answer /docs itself and the mount would
never be reached. Several mounts on one hostname are rendered longest prefix
first, so /docs/api cannot be swallowed by a /docs beside it.
The prefix is absolute with no trailing slash — the matcher is <prefix>*,
and /docs/ would stop /docs itself from matching — and carries no
wildcard, brace or whitespace of its own, because those would rewrite the
matcher into something nobody declared. A mount may not also declare
--redirect-to or --upstream-service: both of those answer a whole
hostname.
What the configuration plane refuses: a mount whose hostname no declaration
owns, because a mount is rendered inside its owner's block and one without an
owner is a block with nowhere to go — the hostname would get no certificate
at all; two mounts at the same prefix on one hostname, because that renders
two handle_path blocks for one path and the first would silently win; and
a second declaration owning a hostname that is already owned, which was
already true.
stado web route for a mount writes no DNS record. The hostname's A
record belongs to the declaration that owns it and already points at this
edge; a second writer of one name is how a mount's removal would look like it
should take the record with it. It verifies https://<hostname><prefix>/
instead of the owner's readiness path, because the owner's readyz is a path
on the owner's application and says nothing about whether /docs reaches
this unit. stado web remove retracts the mount only: one handle_path
block comes out of the owner's site block, the record stays, and every other
mount under that hostname stays.
Source: this website