On this page
Web hosting
Stado takes a Node web product from its repository to a public hostname: the release pipeline builds it on a fleet host, the registry runs it as a managed unit with its environment delivered from Skarbiec, and the selected public connection publishes it. DNS, TLS and forwarding belong to the declared edge; no tunnel vendor is a requirement of the release or object client.
This page describes the product declarations, supported edge adapters, diagnostics and command sequence.
What the fleet already had
Three pieces existed before web hosting and are reused unchanged.
The release pipeline. .wisent-release.json in a product repository
declares platforms, quality gates, a build command, and the files to stage —
the recipe is stado-rs/src/release_pipeline/contract/recipe.rs and the
platforms map is stado-rs/src/release_pipeline/contract/manifest.rs.
stado release submit snapshots the
source, picks a builder whose registry release_platform matches the recipe's
runner_platform, runs the gates and the build there, publishes the staged
bytes into a channel, and promotes them. Platform keys are free names; only
runner_platform is constrained, to darwin-arm64 or linux-amd64.
The service registry. A unit is a ServiceDeclaration — an immutable
source (artifact, sha256) and a run spec (program, args, env) — held
in the canonical registry beside its verification descriptor and its consumers
(stado-rs/src/declaration.rs, stado-rs/src/service_resolution.rs).
stado service deploy renders it to a launchd plist or a systemd user unit and
bootstraps it; stado service secret-sync puts one field of one Skarbiec item
into one environment variable in the unit's runtime env file, over the host
channel, never on a command line; stado service grant-sync reconciles the
unit's Skarbiec consumer grant.
The database plane. stado database resolve <db> --consumer <consumer>
answers with the Skarbiec item that carries the credential, and refuses a
consumer the declaration does not list
(stado-rs/src/cli/database/reads.rs). The value never leaves Skarbiec
through this path; the item name does.
Public connection selection
A public hostname needs a reachable endpoint and a certificate for that name. The deployment chooses how to supply them. A current host address, tunnel process, provider login or DNS answer does not make that choice for the product.
stado web declare selects the web product's edge. The implemented adapters
are stado and cloudflare. A Stado edge can be an existing host recorded by
stado web edge declare; provisioning a new Azure host is one optional way
to obtain that host, not a prerequisite for release downloads. The Cloudflare
adapter uses the declared tunnel and its credential.
For edge: "stado", web_api.edge must name target, address and contact.
stado web status --json preserves a missing or invalid declaration as
edge-unconfigured, includes the exact edge_error, and exits non-zero.
It never treats an unknown expected address as proof that a hostname is
published. stado web edge status reads the same declaration.
The object and release HTTP origin is selected separately with STADO_API_URL
or api.url. Any connection that serves the documented HTTP contract can
carry it. stado host recover-object-api repairs only the local object
service; it does not run a tunnel program or replace public ingress.
Publication still has to read every written object through the selected
public origin and compare the bytes.
Desktop's Services → Web hosting shows the same web status report,
including edge_error, the source endpoint, complete output and exit code.
What was added
stado web — the product-level capability. It owns the shape of a web
product: which release artifact it runs, on which host and port, under which
Skarbiec consumer, with which environment, behind which hostname.
stado dns — the registrar. Namecheap's setHosts call replaces a whole
zone, so a record cannot be changed without re-sending every other record in
it; that is why wisent.com's records were written by a script inside a
product repository. stado dns reads the zone with
namecheap.domains.dns.getHosts, merges one record, and writes the whole zone
back, so the merge lives in Stado and every product's DNS goes through one
command. The credential is the Skarbiec item namecheap_auto (api_user,
api_key, username, client_ip).
stado web build — the build a Node web product's release runs. Twenty-four
landing sites and ten applications do not need thirty-four build scripts, so the
recipe in .wisent-release.json calls one Stado command and the manifest stays
declarative.
stado web edge — the edge host: provision it, declare it, install the
Stado-managed reverse proxy on it, and reconcile the set of hostnames it
terminates.
The .wisent-release.json shape for a web product
A web product declares one platform whose key is web. runner_platform
names the host that builds it. The quality gate and the build both call
stado web, and the stage map names the one tarball the unit is installed
from.
{
"schema_version": 1,
"product": "preferences-landing",
"releases": true,
"version_source": { "kind": "json", "path": "package.json", "pointer": "/version" },
"platforms": {
"web": {
"runner_platform": "darwin-arm64",
"quality": [
{ "name": "web-quality", "argv": ["stado", "web", "quality"] }
],
"build": { "argv": ["stado", "web", "build"] },
"stage": {
"dist/preferences-landing-web.tar.gz": "preferences-landing-web.tar.gz",
"dist/preferences-landing-web.tar.gz.sha256": "preferences-landing-web.tar.gz.sha256"
}
}
},
"promotion": { "channels": ["candidate", "stable"], "reconcile": false }
}
stado web quality reads the checkout the release worker prepared
(WISENT_SOURCE_DIR), installs the locked dependency tree, and runs the
product's own typecheck script when it declares one. stado web build runs
the product's build script and stages a tarball holding .next, public,
package.json, the production node_modules, and a generated launcher that
executes the product's start script on the port the unit passes it. A product
whose build needs a credential names it in the platform's secret_env as
VAR: "item#field".
A build-time value that is not a credential goes in the platform's env
instead, as a literal:
"env": { "NEXT_PUBLIC_SITE_URL": "https://content.wisent.ai" }
stado web build exports both maps into the install and the build. The split
is not cosmetic. secret_env names a Skarbiec item and field, so a public
constant kept there becomes a credential the fleet grants, syncs and audits
for nothing, and its value stops being visible in the diff that changed it.
echo-web's build refuses to run without NEXT_PUBLIC_SITE_URL, and that URL
is on the public internet. One variable in both maps is refused: it would have
two answers.
A static site is a web product too
Six of these sites are directories of files — byk-landing,
handtohuman-landing, kronika-landing and transcript-lake-landing are
index.html and a stylesheet at the top of the repository, jeden's site is
its web directory, and tama-landing builds one into dist/. Vercel served
them by copying the directory, and they are hosted here the same way rather
than being rewritten as Node servers.
One rule decides which a product is: a product that declares a start
script is a server, and a product that does not is a static site. Nothing
else is consulted — not next.config.*, not a dependency on next, not which
directories exist. start is what the launcher runs, so "is there a server to
start" and "what does this product declare" have to be one question. A product
with a build and no start is a site whose build writes its files; a product
with neither is a site whose files are committed, and it needs no
package.json at all.
The site root is what --root names in the build argv, and the checkout
root when nothing names one. There is deliberately no probe order over
public, site, dist and .: a probe order is a guess, and a build that
guesses stages a directory nobody declared.
"platforms": {
"web": {
"runner_platform": "darwin-arm64",
"quality": [{ "name": "web-quality", "argv": ["stado", "web", "quality", "--root", "dist"] }],
"build": { "argv": ["stado", "web", "build", "--root", "dist"] },
"stage": {
"dist/tama-landing-web.tar.gz": "tama-landing-web.tar.gz",
"dist/tama-landing-web.tar.gz.sha256": "tama-landing-web.tar.gz.sha256",
"dist/SOURCE_REVISION": "SOURCE_REVISION"
}
}
}
The artifact holds the site under one fixed directory, site/, plus the same
bin/start-web launcher and a bin/serve-static.mjs server generated into the
tarball. That server uses only node:http, node:fs and node:path: a global
serve would be a dependency of the host rather than of the release, and
npx serve would fetch from the npm registry the first time a unit started —
a network call in the run path, resolving a version nobody pinned, on a host
whose whole point is that it runs the bytes someone published. It resolves a
request as an exact file, then <path>.html, then <path>/index.html, which
is the cleanUrls behaviour these sites were served with, answers 404.html
when the product ships one, refuses anything but GET and HEAD, and confines
every resolved path to the site root.
The launcher is the same script with one line different, so the unit's
contract does not change: the install root resolved from $0, PORT passed by
the unit and refused when unset, WEB_ENV_FILE sourced with assignments
exported, and 127.0.0.1 only. stado web deploy is unchanged — it
installs a tarball and runs bin/start-web, and it cannot tell the two kinds
apart. When the site root is the checkout root, .git, .github, .vercel,
.next, node_modules, release/ and any .env* file are left out: they are
not part of the site, and one of them is a credential.
A web platform beside a binary platform
A product can host a site and ship a binary from one repository. jeden is a
Rust CLI whose documentation site is its web directory, and its
.wisent-release.json declares a runtime contract — the binary, its
launcher and the schema versions the rollout state machine reads.
That contract is declared once for the product, and it applies to the
platforms it describes. A platform whose stage map contains both the
runtime's binary and its launcher ships the runtime and is held to the
contract; a platform whose stage map contains neither ships something else,
like a site tarball, and the contract says nothing about it. A platform with
exactly one of the two is still refused, because that is the half-staged case
the check exists for: a rollout would install something the host cannot
start. A runtime that no platform stages at all is refused too — it is a
declaration nothing checks against the world, and the first rollout would be
what discovered it.
stado release submit reads the same rule when it publishes: a platform that
ships no runtime publishes no binary path, no launcher and no schema
versions, rather than inheriting the product's and claiming a binary that
exists in none of its own bytes. Rollout is unaffected either way — a rollout
target names the platform it rolls out, in the product's rollout policy, so a
web platform is only ever rolled out if someone declared it there.
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.
A hostname in front of an existing service
brama.wisent.com is not a web product. Brama is a Rust binary with its own
managed unit on the mini, answering 127.0.0.1:18081, and it is already
published over public HTTPS — by a Vercel project whose whole content is a
catch-all rewrite to https://charless-mac-mini.tail6443b3.ts.net/:path*.
Vercel contributes exactly one thing there: 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://charless-mac-mini:18081
}
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
brama.wisent.com is two things. Its catch-all belongs to Brama, published
by the declaration above; /docs is 79 documentation pages, versioned with
Brama in vercel-ingress/docs/, which the retiring Vercel ingress served
through 57 /docs* rewrites onto static HTML. Brama does not serve them
itself, so the hostname 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 charless-mac-mini \
--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://charless-mac-mini:3220
}
reverse_proxy http://charless-mac-mini:18081
}
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.
The command sequence
For an existing public edge host, use stado web edge declare. The following
sequence is the optional Azure provisioning path, not a release prerequisite:
stado fleet key generate wisent-edge
stado config set azure.subscription_id <subscription>
stado config set azure.ssh_public_key "<the public key generate printed>"
stado web edge provision wisent-edge --region westus2 \
--size Standard_B2pts_v2 --contact ops@wisent.com
stado web edge status
stado web edge hostnames
provision goes through Stado's own Azure provider
(crate::providers::azure) and the same subscription, resource group, subnet
and SSH key every other Stado-provisioned VM uses, so the edge is cloud
capacity the fleet accounts for like any other — not a machine that exists
outside the plane. It creates a public address, an edge-only security group
opening 80 and 443, a NIC and the VM, in that order, and unwinds them in
reverse if any step fails, naming anything Azure would not release.
Three things must be true before provision can run, and each refusal names
itself. azure.subscription_id and azure.ssh_public_key are configuration
bindings, not derived: the edge is rendered with
disablePasswordAuthentication, so Azure would refuse a VM with no way in at
all. stado fleet key generate wisent-edge mints the pair into the selected
credential store as stado-ssh-wisent-edge and prints the public half, which
is the value the second config set takes; the private half never leaves the
store, and Stado derives that item id from the target name when it opens the
channel. The service principal in the Skarbiec item stado-azure supplies the
credential, so nothing is copied by hand but those two identifiers.
provision requires the subscription's RBAC and spending policy to permit the
requested resources. Its response names a refused Azure operation and the
provider's error. A refusal is not permission to remove a spending policy,
raise a limit or purchase capacity.
Selecting an existing edge
stado web edge declare records an existing target, public address and
certificate contact. This is independent of how the host was obtained.
stado web edge status then reads the declaration, and stado web status
checks each product against its selected edge. No command infers a new
connection from a running tunnel or silently changes a product's provider.
stado web edge remove is the one command that undoes it. It deletes the VM,
the NIC, the security group and the public address in reverse creation order,
waiting for each, because Azure refuses to release an address while an
interface still references it and an orphaned address is billed while
belonging to nothing. It refuses while any product still names the stado
edge — those hostnames' A records point at the address it is about to hand
back — and --orphan-hostnames is how an operator overrides that.
--keep-resources forgets the declaration and touches nothing on Azure, which
is the correct removal for an edge recorded with stado web edge declare
rather than created here.
stado web edge remove
Declare a product:
stado web declare preferences-landing \
--host charless-mac-mini \
--port 3210 \
--hostname preferences.wisent.com \
--consumer preferences-landing-web
Release it from its checkout, which builds it on the fleet and publishes the tarball into the channel:
stado release submit preferences-landing --channel stable
Install and start the unit, deliver its environment, and verify it:
stado web deploy preferences-landing
stado web status preferences-landing --json
deploy installs the released tarball as a managed unit, mints the unit's
Skarbiec consumer grant, and for a product that declares a database resolves
the credential item for that consumer through stado database resolve and
delivers the named field with stado service secret-sync. No value is read by
the operator and none appears in a command line.
Publish the hostname:
stado web route preferences-landing
route reconciles the hostname into the edge, writes the record, and then
waits for https://<hostname> to answer over TLS, reporting the elapsed
seconds. It does not report success on a 2xx that still carries an
x-vercel-id header.
An application with a database and an operator token declares them when it is declared:
stado web declare preferences \
--host charless-mac-mini \
--port 3211 \
--hostname app.preferences.wisent.com \
--consumer preferences-web \
--database preferences \
--database-field pooler_url \
--database-variable DATABASE_URL \
--secret PREFERENCES_OPERATOR_TOKEN=preferences-operator-api#token \
--env NEXT_PUBLIC_BASE_URL=https://app.preferences.wisent.com \
--readyz /api/readyz
stado web list shows every declared product with its host, port, hostname and
unit. stado web remove stops the unit, forgets it, and removes the hostname's
record.
Source: this website