Ingress protocol
The routing document, its ordering rules, the Traefik rendering, and the TLS storage protocol.
Every ingress pulls. Nothing is pushed into an ingress and nothing here calls one: a node renders the fleet's routing and serves it, and whatever sits in front reads it — as the generic document at /v1/ingress/routes, or in a native format at /v1/ingress/routes/<format>. Both surfaces are behind the same ingress token as /v1/storage.
#The routing document
GET /v1/ingress/routes answers FleetRouting.
{
"v": 1,
"routes": [
{
"host": "shop.dev.example",
"application": "shop",
"upstreams": [
{ "node": "vps-a", "address": "10.83.0.1:24417" },
{ "node": "vps-b", "address": "10.83.0.2:24902" }
]
}
],
"api": {
"hosts": ["dev.example"],
"upstreams": [
{ "node": "vps-a", "address": "10.83.0.1:7400" },
{ "node": "vps-b", "address": "10.83.0.2:7400" }
],
"denied": ["/v1/storage", "/v1/ingress/routes"]
}
}#Fields
| Field | Type | Meaning |
|---|---|---|
v | 1 | Document version. Any other value must be refused whole rather than routed against |
routes | array | Every host the fleet answers on, sorted by host, each host named once |
routes[].host | string | Fully qualified: an application's name under a platform domain, or a custom domain that application claimed — or one wildcard key, *.<base>, standing for everything exactly one label under a custom domain claimed with its subdomains |
routes[].application | string | The application answering on this host |
routes[].upstreams | array | Who answers on this host, in priority order |
routes[].upstreams[].node | string | Node id |
routes[].upstreams[].address | string | host:port — the node's advertised address and the process's public port |
api | object? | Absent when the rendering node does not publish the fleet API |
api.hosts | string[] | Bare platform apexes routed to the daemon itself, sorted |
api.upstreams | array | The daemons' own addresses, ordered like a route's |
api.denied | string[] | Path prefixes the ingress must answer itself and never proxy |
#Ordering and priority
upstreams is an ordered priority list, never a weighting. The first entry is where the request goes; the rest exist for when it fails. Nothing in scafo asks for traffic to be split.
The renderer sorts nodes by id. A consumer may ask for one node to be put first, which is how a colocated ingress stays local.
A route never carries an empty upstreams: an application nothing is running for right now is left out of the document altogether, so a host that is absent — and that no wildcard key covers — is a host to answer 404 for.
#What is in the document
Routes are rendered from what live nodes say they serve. Draining nodes and nodes that have stopped beating are dropped here rather than left to health checks — a node that said it is leaving should stop being chosen now, and a black-holed address times out rather than refusing.
For each application that has upstreams, one route is added for its name under every platform domain, and then every custom domain claimed for it. Then, per custom domain claimed with its subdomains, one route for each <env>.<base> whose <holder>-<env> application is serving, and finally the one key *.<base>. First writer wins across those classes, in code-unit order, so a claim can never shadow a platform host and a host claimed exactly always wins over the wildcard enclosing it.
A host nobody serves is left out entirely. Under a wildcard base it then falls to the *.<base> key, which is the base holder's; anywhere else it is a host to answer 404 for. There is no wildcard under a platform domain: every host there is rendered from an application's name.
#The wildcard key
*.<base> is the only key that is not a host. A consumer resolves a request host in two steps:
* and look that up once.Two labels below the base match nothing, as under a wildcard certificate: a.b.shop.example reduces to *.b.shop.example, which is a different base.
A consumer that knows nothing of the key never matches it and answers as it would for a host the fleet does not serve — which is what it did before the key existed, and why the document stays v: 1.
#prefer
| Value | Effect |
|---|---|
| absent / empty | The fleet's own order. This is the only way to ask for it |
self | The node answering the request, resolved to its own id |
| any other value | Read as a node id, applied verbatim. One naming no node in the fleet simply has no effect |
prefer reorders every route and the api upstreams: matching entries first, the rest in their existing order.
#Denied paths
Important
api.denied is the security rule of the fleet API surface, and the ingress must enforce it itself rather than trusting the daemon behind it. A request for one of these prefixes on one of the api.hosts must be answered by the ingress and must never be proxied.
The prefixes are /v1/storage and /v1/ingress/routes — the daemon's own control plane, which name where every process listens. They are meant to be reachable on loopback by whoever holds the ingress token, and never through the public ingress.
Matching is at a path-segment boundary: the prefix exactly, or the prefix followed by / and anything. With /v1/storage denied, /v1/storage and /v1/storage/objects/x are refused and /v1/storagex is not.
An application route always wins over an api host of the same name. The renderer already omits apexes an application answers on, so this is a tie-break that should never be needed.
#Boot behaviour
Until a node's first reconcile pass completes it has no document at all, and both routing routes answer:
503 application/problem+json
{ "type": "urn:scafo:draining", "title": "The fleet's routing is not known yet", "status": 503,
"detail": "no reconcile pass has completed on node \"…\" yet, …" }Treat it as any other fetch failure, never as an empty document: {"v":1,"routes":[]} would be every route in the fleet withdrawn, where a 503 is a pause of a few seconds in one poller.
Every ingress scafo speaks to keeps the last document that parsed when a poll does not return one. Caddy's module logs the failure once and serves what it holds, or — having never held one — resolves scafo.route=unavailable and lets Caddy answer 503. Traefik's http provider logs the error and keeps its previous configuration.
#Traefik rendering
GET /v1/ingress/routes/traefik[?prefer=…] answers Traefik dynamic configuration. The entryPoints come from the node's ingress.entryPoints setting and default to ["web"] — a router attached to an entryPoint the operator does not define is a route that matches nothing.
#Names
| Thing | Name |
|---|---|
| Router and service for a host | scafo_<host> |
Router and service for a *.<base> key | scafo_wild_<base> |
| Router and service for one platform apex's API | scafo_api_<domain> |
| Router denying paths at one platform apex | scafo_apideny_<domain> |
Failover halves, when prefer applies | <key>_local and <key>_peers |
| The one middleware | scafo_retry |
The scafo_ separator is an underscore, which cannot occur in a host.
#Rules
| Router | Rule |
|---|---|
| Host route | Host(`<host>`) |
*.<base> route | HostRegexp(`^[^.]+\.<base with its dots escaped>$`) — exactly one label under the base |
| API deny | Host(`<domain>`) && (Path(`/v1/storage`) || PathPrefix(`/v1/storage/`) || Path(`/v1/ingress/routes`) || PathPrefix(`/v1/ingress/routes/`)) |
| API | Host(`<domain>`) && !Path(`/v1/storage`) && !PathPrefix(`/v1/storage/`) && !Path(`/v1/ingress/routes`) && !PathPrefix(`/v1/ingress/routes/`) |
Each denied prefix expands to exactly two rules — Path(<prefix>) and PathPrefix(<prefix>/) — which is the segment-boundary match.
#Priorities and services
| Setting | Value |
|---|---|
| API deny router priority | 3 |
| API router priority | 2 |
*.<base> router priority | 1 — below every other router this document writes |
| Host router priority | unset (Traefik's default) |
| API deny router service | noop@internal — Traefik's internal no-op backend, so the route is active without dialling anything |
Middleware scafo_retry | { "retry": { "attempts": 3 } }, carried by every router except the deny routers |
| Health check for a host service | path /.well-known/celld/health, interval: "10s", timeout: "3s", hostname: <host> — for a *.<base> service the hostname is the base, never the pattern |
| Health check for an API service | path /.well-known/scafo/health, interval: "10s", timeout: "3s", no hostname |
| Server URL | http://<upstream address> |
The *.<base> priority is stated rather than left to Traefik because Traefik's default priority is the length of the rule, which would put a long HostRegexp ahead of a short Host — precisely backwards for a fallback. The API routers are explicit for the same reason.
With prefer naming a node that answers on a route and at least one other node answering too, the service becomes a failover from a balancer over the preferred node to a balancer over the rest. Otherwise it is a single load balancer over every upstream in document order.
When there are no routers at all the answer is {} rather than empty maps — empty maps make Traefik retain its previous provider configuration.
#Example
The document above, rendered with entryPoints: ["web"] and ?prefer=vps-a:
{
"http": {
"routers": {
"scafo_app.dev.example": {
"rule": "Host(`app.dev.example`)",
"service": "scafo_app.dev.example",
"entryPoints": ["web"],
"middlewares": ["scafo_retry"]
},
"scafo_apideny_dev.example": {
"rule": "Host(`dev.example`) && (Path(`/v1/storage`) || PathPrefix(`/v1/storage/`) || Path(`/v1/ingress/routes`) || PathPrefix(`/v1/ingress/routes/`))",
"service": "noop@internal",
"entryPoints": ["web"],
"priority": 3
},
"scafo_api_dev.example": {
"rule": "Host(`dev.example`) && !Path(`/v1/storage`) && !PathPrefix(`/v1/storage/`) && !Path(`/v1/ingress/routes`) && !PathPrefix(`/v1/ingress/routes/`)",
"service": "scafo_api_dev.example",
"entryPoints": ["web"],
"middlewares": ["scafo_retry"],
"priority": 2
}
},
"services": {
"scafo_app.dev.example": {
"failover": {
"service": "scafo_app.dev.example_local",
"fallback": "scafo_app.dev.example_peers"
}
},
"scafo_app.dev.example_local": {
"loadBalancer": {
"servers": [{ "url": "http://10.83.0.1:24417" }],
"healthCheck": {
"path": "/.well-known/celld/health",
"interval": "10s",
"timeout": "3s",
"hostname": "app.dev.example"
}
}
},
"scafo_app.dev.example_peers": {
"loadBalancer": {
"servers": [{ "url": "http://10.83.0.2:24902" }],
"healthCheck": {
"path": "/.well-known/celld/health",
"interval": "10s",
"timeout": "3s",
"hostname": "app.dev.example"
}
}
},
"scafo_api_dev.example": {
"failover": {
"service": "scafo_api_dev.example_local",
"fallback": "scafo_api_dev.example_peers"
}
},
"scafo_api_dev.example_local": {
"loadBalancer": {
"servers": [{ "url": "http://10.83.0.1:7400" }],
"healthCheck": {
"path": "/.well-known/scafo/health",
"interval": "10s",
"timeout": "3s"
}
}
},
"scafo_api_dev.example_peers": {
"loadBalancer": {
"servers": [{ "url": "http://10.83.0.2:7400" }],
"healthCheck": {
"path": "/.well-known/scafo/health",
"interval": "10s",
"timeout": "3s"
}
}
}
},
"middlewares": {
"scafo_retry": { "retry": { "attempts": 3 } }
}
}
}#Storage protocol v1
The other half of the ingress token: what a colocated TLS terminator keeps its certificates, account keys and issuance locks in. The daemon holds the bucket credentials; the ingress holds a loopback URL and a bearer token.
#Headers
Every request carries both:
| Header | Value |
|---|---|
Authorization | Bearer <token>, the trimmed ingress token |
X-Scafo-Storage-Version | 1 |
The version header exists so a daemon can reject a client it cannot serve. An absent or empty header means version 1; any other value is invalid-input.
#Key encoding
Keys use / as a component separator and have no leading or trailing slash:
certificates/acme-v02.api.letsencrypt.org-directory/example.com/example.com.crtA key maps onto the request path as-is: each component is percent-encoded individually and the / separators are preserved, so the daemon sees the same hierarchy the caller used. Keys passed as query parameters (list, stat) use ordinary form encoding.
A key must be relative, made of printable ASCII, and carry no empty, . or .. segment. Anything else is invalid-input, Invalid storage key.
#Endpoints
| Method | Path | Request | Success | Not found |
|---|---|---|---|---|
PUT | /v1/storage/objects/<key> | raw value bytes | 204 | — |
GET | /v1/storage/objects/<key> | — | 200 with raw value bytes | 404 |
DELETE | /v1/storage/objects/<key> | — | 204; deletes the key and its subtree | 404 |
GET | /v1/storage/list?prefix=<p>&recursive=<true|false> | — | 200 with JSON []string of keys | 404 |
GET | /v1/storage/stat?key=<k> | — | 200 with a key-info object | 404 |
POST | /v1/storage/locks/<name> | — | 204, lock acquired | — |
DELETE | /v1/storage/locks/<name> | — | 204, always, idempotent | — |
#The 404 rule
GET /v1/storage/list answers 404 when nothing exists at or under the prefix — a 200 always carries at least one key. CertMagic expects listing a missing prefix to be reported as "not found", not as an empty result, and translates a 404 into an error wrapping io/fs.ErrNotExist.
Important
A node daemon must answer 404, not 500, for keys that do not exist. Getting this wrong makes CertMagic treat absent certificates as infrastructure failures.
#Stat body
{
"key": "certificates/acme/example.com/example.com.crt",
"modified": "2026-04-01T12:30:00Z",
"size": 4096,
"isTerminal": true
}| Field | Type | Meaning |
|---|---|---|
key | string | The key as it was asked about |
modified | RFC 3339 string or null | When the value was last written; null for a key that only has keys beneath it, and becomes CertMagic's zero time |
size | number | Length of the decoded value; 0 for a key that only has keys beneath it |
isTerminal | boolean | true for a key holding a value, false for one that merely acts as a prefix |
#Lock semantics
POST /v1/storage/locks/<name> is try-acquire and never waits. It answers 409 Conflict (urn:scafo:cas-conflict) when the lock is currently held elsewhere; the client then waits one second and retries indefinitely until the lock is granted or its context is cancelled. Any other error status ends the attempt immediately rather than being retried.
Exactly one holder may hold a given name at a time. The daemon expires locks left behind by a crashed holder: a holder restamps every 5 s and a lock unrefreshed for 10 s may be taken over. That expiry is what makes several ingress instances able to share one bucket.
A node that loses a refresh drops the lock locally, whatever went wrong: a refused conditional write and an unreachable bucket are indistinguishable, and going on holding a lock the process may no longer have is the one outcome a lock exists to prevent.
DELETE /v1/storage/locks/<name> is idempotent and answers 204 even when the lock is not held.
#Client behaviour
The Caddy storage module uses a dedicated HTTP client per fleet, with a per-request timeout of that fleet's storage_timeout (30 seconds by default), which does not bound Lock — that blocks until the lock is acquired or its context is done. It performs no retries other than the lock poll: the daemon is colocated, so a failure is a fault to report rather than a transient to paper over.
The routing poller uses a 10-second per-request timeout and waits poll_interval between attempts rather than on a schedule.
#Errors
Non-2xx responses on both protocols use application/problem+json. The title and detail members, when present, are surfaced in the error a client reports; otherwise the error reports the status code alone.