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