On this page
Public origins
A public origin is a declaration. public_origins in the canonical registry
names each hostname this fleet publishes to the open internet: which target
publishes it, by which publication, to which local upstream, and which paths
that publication carries. stado web origin declares, lists, reports and
converges them, and refuses to declare a hostname the public DNS root does not
carry.
This is the third of the three choices channels keeps apart —
a host-control route, a service endpoint and a public download origin are not
one decision. The edge that serves a web product is
web hosting's web_api.edge; the one durable origin
release clients read is the promise in release.
Why this is declared
An origin held only in a deployment's environment variable is not declared,
validated, converged or reported, so the only way to learn which origin the
deployment selected is to provoke a failure and read it out of the 503 body.
And a node can be serving while its name does not resolve: when the public
nameserver for the node's *.ts.net name answers NXDOMAIN, nothing outside
the tailnet reaches the origin, every public read carries originDiagnosis
with state dns_unresolved (channels), and release gates
refuse with error_code=infra_down.
A hostname published by Funnel becomes public only once Tailscale's control plane creates the public record for the tailnet. The product does not own that record and cannot converge it. So this capability converges what the product does own — the node's own publication of declared paths to a loopback upstream — and reports the rest by name, with the public resolver's own answer beside it.
Publications
publication names how the hostname reaches the internet, and
stado web origin declare defaults to tailscale-funnel.
publication |
What publishes the name | What it needs |
|---|---|---|
tailscale-funnel |
The target node's own Funnel handler table. | The node's own *.ts.net name and a tailnet policy that grants that node Funnel; the public record is created by Tailscale's control plane while Funnel is on, never by this fleet. |
web-edge |
The stado web declaration that owns the same hostname, published in front of the serving service with --upstream-service, on the edge that declaration names (web hosting). converge runs stado web route for it. |
A hostname in a zone the operator holds, and a declared edge: stado web edge declare or stado web edge provision for the stado edge, or a Cloudflare-served zone and API credential for the cloudflare edge. |
A web-edge origin is itself the address release clients read, so api.url
(STADO_API_URL) points at https://<hostname>. Its edge_selection.state is
agrees when it does and differs when it does not, and the release read-back
through that URL is the proof it serves. declare refuses a web-edge origin no
web declaration owns, and the registry refuses a web-edge origin whose hostname
is a tailnet name.
The declaration
"public_origins": [
{
"name": "release-object",
"hostname": "example-host.example-tailnet.ts.net",
"target": "example-host",
"publication": "tailscale-funnel",
"upstream": "http://127.0.0.1:8765",
"paths": ["/api/release/object"]
}
]
| Field | What it declares |
|---|---|
name |
A lowercase identifier, unique in the document. It is the argument every stado web origin subcommand takes. |
hostname |
A bare public DNS name — no scheme, no port, no path. What a client outside this deployment resolves. |
target |
The declared registry target that publishes the name. A target the document does not carry is refused. |
publication |
How the name is published: tailscale-funnel (the default) or web-edge, as publications describes. |
upstream |
The loopback address the publication forwards to, http://127.0.0.1:<port>. A public origin forwards to a local service, never to another public name. |
paths |
The absolute paths that publication carries, at least one, none with a trailing slash. A path outside the list is not published by this declaration. |
The document is pushed the way every registry change is pushed: validated as a
whole, compare-and-swap against the generation counter
(registry). It is readable at /api/registry.json.
Declaring one
stado web origin declare release-object \
--hostname example-host.example-tailnet.ts.net \
--target example-host \
--publication tailscale-funnel \
--upstream http://127.0.0.1:8765 \
--path /api/release/object
--path repeats. --json prints the receipt. The write goes through the
registry's own validated compare-and-swap, and a hostname with no public A or
AAAA record is refused before any write:
refusing to declare public origin "<name>": <hostname> has no public A or AAAA record, so no public edge could fetch it; publish the name first, then declare it
One name is exempt: a tailscale-funnel declaration of the target's own
tailnet name. Tailscale publishes that record only while the node's Funnel is
on, and converge is what turns Funnel on for a declared origin, so demanding
the record first made such an origin impossible to declare. It is accepted
with "publicness": "pending-publication" in the receipt, and converge and
status judge the public name afterwards. A resolved name is declared with
"publicness": "public".
A web-edge origin whose hostname no web declaration owns is refused first:
refusing to declare public origin "<name>": no `stado web` declaration owns <hostname>, so no edge terminates it; declare one in front of the service that serves the paths with `stado web declare <name> --hostname <hostname> --upstream-service <service>` and publish it with `stado web route <name>`
That order is deliberate. A declaration whose name the DNS root does not carry describes an origin no public edge can fetch, and writing it would publish a promise the fleet cannot keep.
stado web origin list [--json] prints the declarations this registry carries.
stado web origin remove NAME [--json] withdraws one. Removing a declaration
withdraws the product's claim about the name; it does not unpublish the node.
Reading the report
stado web origin status [NAME] --json reports each declared public origin with its verdict, DNS resolution, publication and edge selection, and exits non-zero when any verdict is not serving: Reading a public origin report explains every field and state.
Converging one
stado web origin converge NAME [--apply] --json makes the declared
publication match the declaration. Without --apply it is a plan and its
status is unchanged, planned or refused; nothing is written. A web-edge
origin converges through stado web route for the web declaration that owns its
hostname — that command prints its own receipt first — and the origin receipt
carries web_declaration with the product, edge and upstream service instead of
handlers and funnel.
{
"schema": "stado.public-origin-converge-receipt.v1",
"name": "release-object",
"target": "example-host",
"publication": "tailscale-funnel",
"status": "converged",
"handlers": [
{ "path": "/api/release/object", "upstream": "http://127.0.0.1:8765/api/release/object", "change": "present" }
],
"funnel": { "enabled": true, "ports": [443] },
"resolution": { "state": "dns_unresolved", "resolver": "…", "answers": [], "detail": "…" },
"readback": { "state": "unreachable", "status": null, "detail": "…" },
"refusal": "the declared origin example-host.example-tailnet.ts.net is published by this node's Tailscale Funnel but has no public A or AAAA record, so no public edge can fetch it"
}
status is converged when the node's publication changed, unchanged when
it already matched, and refused when convergence cannot produce a public
origin — with refusal saying why in one sentence. handlers names each
declared path and whether it was already present or was added; readback is
the result of reading the origin afterwards, which is unreachable for a name
the DNS root does not carry. Convergence does not create that record: the
receipt reports the state and refuses, and the tailnet name is published
through Tailscale's own control plane.
GET /api/release/origin
A public, bearer-free route on the deployment that answers which origin it
selected and attempts a read of that origin's object route without a URI.
It serves no object payload. HTTP 200 means the selection and diagnostic
document were returned, not that the origin is serving: inspect
originDiagnosis.probe.status and .error. The separately identified
node:http or node:https probe reports its DNS, TCP, TLS and HTTP phases,
actual peer address, timings and structured errors. It does not replace the
object proxy's original fetch result.
The probe records phase (configuration, dns, tcp, tls, http or
complete), elapsed ms, timings_ms, resolved and connected addresses,
connected port, TLS protocol and certificate authorization, and the received
HTTP status. A response-body failure retains any status already received.
failure and the original request's cause_chain preserve runtime error codes,
system call, address, port, nested causes and separate connection attempts.
DNS answers alone never assign the failure to TCP or TLS.
The Node requests use a three-second socket inactivity budget, explicitly
reported as timeout_kind: socket_inactivity. This is not a total-operation
deadline or a DNS lookup deadline. An HTTP error status is a received response;
outcome: error or timed_out means the read failed. The Stado CLI applies its
own ten-second bound when reading this diagnostic endpoint.
Every answer carries cache-control: no-store.
| Answer | Body |
|---|---|
| HTTP 200 | {"schema":"stado.release-origin-selection.v1","origin":"<selected origin>","originDiagnosis":{…}}. The diagnosis retains each DNS query and its outcome, plus the separate origin probe. States are dns_resolved, dns_unresolved, dns_unavailable, or dns_not_required for a literal IP address. |
| HTTP 503 | {"error":"release origin is not configured"} — the deployment selected no origin at all. The sibling object route answers that same sentence for that same condition. |
| HTTP 400 | {"error":"exactly zero query fields are accepted"} — the route takes no input, so any query field is a caller reading another route's contract. |
stado web origin status reads this route and records what it said in
edge_selection, which is how a declaration and a deployment that disagree
become one reported row instead of two separate investigations.
What this does not do
This capability does not choose a network provider or derive an origin from a
host-control route. A release client still reads one durable origin, and the
publication provider owns its public DNS records. DNS resolution, a configured
Funnel and an equal selected-origin string do not prove that object bytes can
be read. Read an exact release coordinate with stado storage get URI DESTINATION
or inspect stado storage stat URI --json: an unavailable channel remains
unavailable, never an absent release.
Stado Desktop never shells out for any of this. It runs web origin status as
a read-only command and web origin converge as a mutation through the
authenticated loopback operator route, which carries an argument vector rather
than a shell string, and shows the receipt it got back.
Source: this website