Caddy modules
scafo-ingress: the scafo app and the modules that read it, their builds, images, options, request variables and every environment variable.
Five modules in one repository and one binary. The scafo app is where the fleets are configured, once; the other four take no options and read it. A fleet is one Scafo installation: one node endpoint, one bearer token, one routing document, one bucket. One Caddy can stand in front of one fleet or several, and both are the same configuration and the same code path.
Caddy holds an endpoint URL and a bearer token per fleet, which authorize that node's /v1/storage and /v1/ingress/routes and nothing else. The node holds its bucket credentials and does the reading and writing, so there is no path from a compromised Caddy to the rest of any bucket.
| Module | Caddy module ID | What it does |
|---|---|---|
| The app | scafo | Where the fleets are, which token opens them, which DNS providers exist |
| TLS storage | caddy.storage.scafo | Keeps certificates in each fleet's own bucket, routed per name |
| Dynamic upstreams | http.reverse_proxy.upstreams.scafo | Resolves a request's backends across every fleet |
| On-demand permission | tls.permission.scafo | Decides, at handshake time, which names may get a certificate |
| Plain-HTTP matcher | http.matchers.scafo_plain_http | Matches a host whose fleet asked not to be redirected to HTTPS |
Module path: github.com/sandros94/scafo-ingress. License: MIT.
#Building
xcaddy build --with github.com/sandros94/scafo-ingress # every module here
xcaddy build --with github.com/sandros94/scafo-ingress/storage # the storage one and the app
xcaddy build --with github.com/sandros94/scafo-ingress/ingress # the HTTP ones and the app
xcaddy build --with github.com/sandros94/scafo-ingress/permission # the permission one and the appThe repository root imports every module in it, so naming the root compiles them all in; naming a single package compiles that module and the scafo app it reads, and nothing else.
#Dockerfile build arguments
The repository's Dockerfile does the same inside the official caddy:<version>-builder image and copies the result into the matching runtime image.
| Build argument | Default | Meaning |
|---|---|---|
CADDY_VERSION | 2.11.4 | Selects both the xcaddy builder and the runtime base, so the two never drift apart. It is the canonical Caddy pin for the repository: go.mod's Caddy requirement is kept equal to it |
DNS_PROVIDERS | empty | Whitespace-separated list of pinned Go module paths to compile in as well. Empty is the lean flavour |
docker build -t my-caddy .
docker build -t my-caddy --build-arg CADDY_VERSION=2.10.0 .
docker build -t my-caddy --build-arg DNS_PROVIDERS=github.com/caddy-dns/gandi@v1.0.3 .One Dockerfile and one xcaddy invocation stand behind both published flavours, so a custom build is the published build with a different argument.
#Images, tags and binaries
Published for linux/amd64 and linux/arm64, in two flavours. Lean is Caddy with only the modules above. -dns is the same build with a curated set of DNS-01 provider plugins compiled in, for the managed wildcard certificates — a Caddy plugin can only arrive at compile time.
| Image | Release binaries | |
|---|---|---|
| Lean | ghcr.io/sandros94/scafo-caddy:latest, :X.Y.Z, :edge | scafo-caddy-linux-{amd64,arm64} |
| With providers | :latest-dns, :X.Y.Z-dns, :edge-dns | scafo-caddy-dns-linux-{amd64,arm64} |
Each binary ships a .sha256 alongside it and is the same binary that is inside the image of its flavour, lifted out of that build's builder stage. Every release also carries one caddy-version file naming the Caddy both flavours embed.
Note
Take the lean flavour unless you want managed wildcards. HTTP-01 needs no plugin, and neither does the on-demand TLS that covers the names a fleet routes exactly. The providers are there for the wildcard. Naming a DNS provider this build does not carry fails startup with an error listing the ones it does.
#DNS providers in the -dns flavour
Exact pins, from dns-providers.txt, which is the whole list.
| Provider | Go module | Caddy module |
|---|---|---|
| Bunny | github.com/caddy-dns/bunny@v1.2.0 | dns.providers.bunny |
| Cloudflare | github.com/caddy-dns/cloudflare@v0.2.4 | dns.providers.cloudflare |
| DigitalOcean | github.com/caddy-dns/digitalocean@v0.0.0-20250606074528-04bde2867106 | dns.providers.digitalocean |
| Hetzner | github.com/caddy-dns/hetzner/v2@v2.0.1 | dns.providers.hetzner |
| OVH | github.com/caddy-dns/ovh@v1.1.0 | dns.providers.ovh |
| Route 53 | github.com/caddy-dns/route53@v1.6.2 | dns.providers.route53 |
The last path element, minus any /vN suffix, is the Caddy module name. The release build asserts that every entry registered under that name, so a provider that breaks the convention fails the build rather than going missing quietly. The DigitalOcean pin is a Go pseudo-version because that repository has never cut a tag.
A provider that is not in the list is added the same way the curated ones arrive — --with github.com/caddy-dns/gandi@v1.0.3 to xcaddy, or the same string in DNS_PROVIDERS.
#The scafo app
The app is configured in one of two ways, and never both at once:
- From the environment. The published image ships a default Caddyfile that declares an empty
scafoblock, and the app fills itself in from the process environment. No Caddyfile to write, no volume to bind. See every environment variable. - In the configuration, as below. When the configuration declares at least one fleet, the environment is not consulted for fleets at all. The same rule applies to DNS providers on their own. The two can never be half-merged.
scafo is a global option, so it goes in the leading braces block:
{
scafo {
fleet client-a {
endpoint http://10.0.0.1:7400
token {env.TOKEN_A}
}
fleet client-b {
endpoint http://10.0.0.2:7400
token_file /run/secrets/client-b
http_redirect off
}
dns cloudflare {
api_token {env.CLOUDFLARE_API_TOKEN}
}
acme_delegation acme.example.com
acme_email ops@example.com
}
}In JSON every option keeps its Caddyfile name, and a fleet's name is its name member.
#scafo options
| Option | Description |
|---|---|
fleet <name> { … } | One fleet. Repeatable. The name is a slug: lowercase alphanumerics separated by single hyphens |
dns <module> { … } | The default DNS provider. Every option in the block is one of the provider module's own JSON fields, except zones |
dns <name> <module> { … } | A named DNS provider, with zones <zone…> saying which zones it holds. The name is a slug, like a fleet's, and is not the module name — an operator may hold two accounts at one provider |
acme_delegation <fqdn> | The fixed name a delegated DNS challenge record is written at. See wildcard certificates |
acme_email <email> | The contact address registered with the ACME CA for the managed wildcards |
acme_ca <url> | The ACME directory URL for the managed wildcards. Unset uses Caddy's default |
A value written in the configuration wins over the environment for acme_delegation, acme_email and acme_ca; the environment fills in only what the configuration left empty.
#fleet options
| Option | Type | Default | Description |
|---|---|---|---|
endpoint | URL | — | Base URL of that fleet's node API, for example http://127.0.0.1:7400. A path prefix is allowed and is preserved. Must use http or https. Required |
token | string | — | The bearer token, inline. Trimmed of surrounding whitespace |
token_file | path | — | Path to a file containing the bearer token. Read once at startup and trimmed, so a trailing newline is fine |
prefer | string | self | Whose upstreams should head every route. self means the node answering the request. Any other value is a node id, sent through verbatim. none asks for nothing — it is read here and not sent, and the node then leaves the fleet's own order alone |
poll_interval | duration | 5s | How long to wait between fetches of this fleet's routing document. Must be positive |
timeout | duration | 10s | Bounds one fetch of the routing document. Must be positive |
storage_timeout | duration | 30s | Bounds one storage request to this fleet's node. Must be positive. It does not bound a lock acquisition, which blocks until the lock is granted or the caller's context is done |
http_redirect | on/off | on | off serves this fleet's hosts over plain HTTP as well, and leaves every other fleet alone |
Warning
Exactly one of token and token_file. Setting both is a configuration error, and so is setting neither; both fail at startup, naming both options. Once resolved the two are indistinguishable.
#DNS providers
The dns block mirrors the environment form exactly, and every field value is a string:
dns cloudflare {
api_token {env.CLOUDFLARE_API_TOKEN}
}
dns other cloudflare {
zones a.example b.example
api_token {env.OTHER_CLOUDFLARE_API_TOKEN}
}A wildcard base is solved by the named provider whose zones entry is its longest suffix, matched at a label boundary; otherwise by the default provider, the one with no zones of its own; otherwise, when zones are declared somewhere and acme_delegation is set, by the delegation. When no zone is declared anywhere, the default provider takes everything.
#Placeholders
Every value accepts Caddy's global placeholders, most usefully {env.VAR}. The app is provisioned before it serves anything, so request-scoped placeholders are not available — only environment and system values.
| Form | Behaviour |
|---|---|
{env.SCAFO_INGRESS_TOKEN} | Resolved while the app is provisioned and never written back into the configuration. caddy adapt emits the placeholder verbatim, and the admin API's GET /config/ returns it verbatim, so only the running process holds the token |
{$SCAFO_INGRESS_TOKEN} | Substituted before parsing, so it bakes the secret into the adapted JSON. Do not use it for a token |
The endpoint is expected to be loopback or an equally private network path. Do not expose a node's API publicly on the strength of the token alone; a node reached over a shared network carries the token over that network, so keep it private or use https.
#caddy.storage.scafo
storage is a global option. It takes no options: the fleets are the app's.
{
scafo {
fleet default {
endpoint http://127.0.0.1:7400
token {env.SCAFO_INGRESS_TOKEN}
}
}
storage scafo
}An option inside storage scafo is refused at startup with an error that says where fleets are configured instead.
#Where each key goes
One Caddy has one certificate keyspace, and every key in it that names a domain is routed to the fleet that owns that domain — through that fleet's node, with that fleet's token. A certificate never crosses a fleet. Ownership follows the same resolution routing uses, plus one rule for a *.base certificate: it belongs to the fleet whose document carries *.base as a route, answers on base as its platform apex, or asks for base in its wildcards.
| Key | Belongs to |
|---|---|
certificates/<issuer>/<domain>/<domain>.{crt,key,json} | the fleet owning <domain> |
ocsp/<domain>-<hash> | the fleet owning <domain> |
acme/<issuer>/challenge_tokens/<domain>.json | the fleet owning <domain> |
lock issue_cert_<domain> | the fleet owning <domain> |
acme/<issuer>/users/… — the ACME account | the ingress's local disk |
last_clean.json, rw_test_<n> | the ingress's local disk |
locks storage_clean, register_acme_account…, ari_<id> | the ingress's local disk |
Caddy's own pki/…, ech/…, anything else | the ingress's local disk |
Keys that name no domain have no fleet, so they live on the ingress's own disk, in Caddy's default file storage under its data directory. The ACME account is the important one: it is the ingress's, not any fleet's, and putting it in one fleet's bucket would let that fleet impersonate the ingress to its certificate authority. Keep the data directory (/data in the image) on a volume.
Two consequences:
- CertMagic's storage check writes and reads back an
rw_test_<random>key before an issuance. It names no domain, so it checks the local disk rather than a fleet's bucket; a fleet's bucket is exercised by the certificate writes themselves. - The renewal lock CertMagic takes while fetching ARI names no domain, so it is taken locally: a lock between the goroutines of this ingress rather than between several of them.
A domain nobody owns gets no default fleet. Falling back to "the first one" would put one customer's private key in another customer's bucket. A read of such a key is reported as missing, and a write is refused with an error naming the domain.
A listing of a prefix that names a domain is sent to that fleet alone. A listing whose children name domains (certificates/<issuer>, ocsp) is sent to every fleet and merged, keeping a key only if it came back from the fleet that owns its domain — so a stale key left in one fleet's bucket for a name another fleet owns is never loaded, renewed or deleted through the wrong node.
Each fleet's storage requests use a dedicated HTTP client bounded by that fleet's storage_timeout. There are no retries other than the lock poll.
#http.reverse_proxy.upstreams.scafo
It goes inside a reverse_proxy block as dynamic scafo, and takes no options.
reverse_proxy {
dynamic scafo
lb_policy first
}An option inside dynamic scafo is refused at startup with an error that says where fleets are configured instead. prefer, poll_interval and the timeouts are per fleet, so two site blocks cannot disagree about a fleet.
#Resolving a host
Every fleet has its own poller and its own cached routing document; a request is resolved against all of them at once, so it costs a map lookup and no round trip. The request's host, lowercased and without its port, is tried in this order, and the first match answers:
*, looked up once.An exact route beats a wildcard across fleets, just as it does within one. When two fleets publish the same host, the one that published it first keeps it for as long as it goes on publishing it, and the conflict is logged once per change rather than once per poll. Two fleets claiming a host in the very same refresh have no "first", so the order they were configured in decides: declaration order in a Caddyfile, alphabetical by name when they came from the environment.
A fleet whose node is unreachable affects only its own hosts: its last document keeps answering.
#Request variables
The module cannot choose a status code — Caddy's answer to no upstreams is 503 — so it records why in four request variables and leaves the answer to a handle_errors route.
| Variable | Value |
|---|---|
scafo.route | ok, denied, unknown or unavailable |
scafo.host | The normalized host the lookup was keyed by: the request host, minus any port, lowercased |
scafo.matched | The document key that answered: a route host, a wildcard such as *.example.com, or a fleet API host. Empty when nothing matched |
scafo.fleet | The name of the fleet that answered. Empty when none did |
scafo.host is always the request's own host; scafo.matched tells a wildcard's traffic apart from a route named for one application, and scafo.fleet tells one installation's traffic from another's.
scafo.route | Meaning |
|---|---|
ok | Some fleet's document named upstreams for this request. A 503 with ok means they were all unreachable |
unknown | No fleet serves this host |
denied | The host is a fleet API host and the path is one the ingress must answer itself rather than proxy. Enforced whatever the error route says |
unavailable | Not one fleet has a routing document yet, so nothing is known about this host either way. Deliberately not unknown: a cold start is not a missing deployment. Once any fleet has answered, a host nobody names is unknown |
These are request variables. A vars matcher branches on them, and any handler that expands request placeholders — respond, header, rewrite — can read {http.vars.scafo.fleet}. Caddy's access-log encoder does not run in a request context, so a fleet name reaches the log only by way of something that does, such as a response header the log already records.
Turning the first two into a 404 takes built-in directives only:
handle_errors {
@missing vars scafo.route unknown denied
handle @missing {
respond "No deployment answers to {http.vars.scafo.host}." 404
}
respond "{err.status_code} {err.status_text}" {err.status_code}
}denied is answered the same way as unknown on purpose: a distinct status would confirm that the path exists.
#The pollers
One poller per fleet, not one per site block: however many handlers use dynamic scafo, a node is asked once per poll_interval. A fetch whose bytes are identical to the last one's is dropped rather than republished, so the cross-fleet view is rebuilt, and a conflict logged, only when something actually changed. A document whose v is not 1 is refused whole, and the last one understood keeps answering.
| Situation | Behaviour |
|---|---|
| First fetch, during provisioning | Attempted, so a healthy pair begins already routing. Its failure does not fail startup — it is logged and the poller carries on |
| Later fetch failures | Cost nothing: the last document that parsed keeps answering, and the log says so once per state change rather than once per tick |
| A configuration error | Fails startup. A missing, unreadable or empty token aborts with an error naming the option |
| Reload | The incoming configuration takes its reference to each poller before the outgoing one gives its own back, so polling never pauses and the last document keeps answering across the swap — even when a node happens to be down. A poller stops only when the last configuration using it has gone |
Each fetch is bounded by the fleet's timeout, and the interval is waited between attempts rather than on a schedule, so a slow answer delays the next request instead of stacking up behind it.
#Load balancing
Each node returns a route's upstreams in priority order, never as a weighting. lb_policy first takes that at its word: with prefer self, the head of the list is the process on this very node, so a request that arrives here stays here whenever it can.
reverse_proxy {
dynamic scafo
lb_policy first
lb_retries 2
lb_try_duration 5s
fail_duration 30s
max_fails 1
}| Setting | Value | Effect |
|---|---|---|
lb_policy | first | Uses the head of the node's order and reaches for the rest only when it fails |
max_fails | 1 | Must stay at 1. first picks the first upstream not yet marked down, and a failed dial does not mark one down until it has failed max_fails times — so with a higher value every retry of the same request goes back to the same dead process |
fail_duration | e.g. 30s | How long a failed upstream stays skipped, and what lets it back in once it is up |
lb_retries, lb_try_duration | e.g. 2 and 5s | Bound how long one request keeps trying. Caddy stops at whichever it reaches first |
Warning
A request to an unknown host takes the same "no upstreams" path as a route whose backends are all down, so it spends the same retry budget before the 503 can become a 404. A dynamic upstream that resolved to nothing resolves to nothing again on the next attempt, so keep the retries small.
#tls.permission.scafo
The on-demand TLS permission. It goes in the on_demand_tls global option and takes no options.
{
on_demand_tls {
permission scafo
}
}A name is approved only when some fleet's current routing document names it as an exact route host or as a fleet API host. The answer comes from the documents already in memory, so a handshake costs a map lookup and no HTTP call — which matters, because this runs on the handshake path of every name the ingress has no certificate for, and a stranger decides how often that is.
- A name covered only by a
*.entry is refused. An on-demand certificate per arbitrary subdomain is a door anyone can knock on: point a thousand names at the ingress and each handshake asks the CA for another certificate under a customer's domain, until that customer's weekly issuance limit is gone. The wildcard those names are covered by is obtained once, through DNS-01, and covers all of them. - A name in no document is refused, and so is every name while no document has arrived yet. "I do not know" is not "yes", and a cold start must not become an issuance for whatever connects first.
#http.matchers.scafo_plain_http
A request matcher with no options. It matches a host whose fleet is configured with http_redirect off; a host no fleet owns does not match. Caddy decides HTTP-to-HTTPS redirects per site block from the host names written in the configuration, and this ingress writes none down — it serves whatever the routing documents say, and that changes without a reload — so the decision is made per request instead:
http:// {
@plain scafo_plain_http
handle @plain {
reverse_proxy {
dynamic scafo
}
}
handle {
redir https://{host}{uri} 308
}
}The default is the redirect, and only a fleet that asked for plain HTTP gets it. The ACME HTTP challenge is unaffected either way: Caddy answers it in the server itself, before any route or matcher runs.
#Every environment variable
Read by the scafo app when the configuration declares no fleet, which is how the published image is meant to run. <NAME> is [A-Z0-9_]+, and becomes a fleet or provider name lowercased with underscores turned into hyphens: SCAFO_FLEET_CLIENT_A_ENDPOINT is fleet client-a.
#Fleets
| Variable | Description |
|---|---|
SCAFO_FLEET_ENDPOINT | Node API base URL of the single fleet, which is named default |
SCAFO_FLEET_TOKEN | Its bearer token, inline |
SCAFO_FLEET_TOKEN_FILE | Its bearer token, as a path. Exactly one of the two |
SCAFO_FLEET_PREFER | self (default), a node id, or none |
SCAFO_FLEET_POLL_INTERVAL | Between fetches of the routing document. Default 5s |
SCAFO_FLEET_TIMEOUT | Bounds one fetch. Default 10s |
SCAFO_FLEET_STORAGE_TIMEOUT | Bounds one storage request. Default 30s |
SCAFO_FLEET_HTTP_REDIRECT | on (default) or off. Also accepts true/false, yes/no, 1/0 |
SCAFO_FLEET_<NAME>_… | The same eight, for a fleet called <NAME> |
Durations are Caddy durations (5s, 1m30s) and must be positive; 0s is an error rather than "use the default".
Warning
The single and the named forms are mutually exclusive. SCAFO_FLEET_TOKEN beside SCAFO_FLEET_A_TOKEN fails startup naming both variables, and so does SCAFO_FLEET_ENDPOINT beside SCAFO_FLEET_DEFAULT_ENDPOINT. A SCAFO_FLEET_* variable this build does not recognize fails startup naming it and listing the ones it does, and a fleet with an endpoint and no token fails too.
#DNS providers
| Variable | Description |
|---|---|
SCAFO_DNS_PROVIDER | The default provider's Caddy module name, as caddy list-modules prints it minus the dns.providers. prefix |
SCAFO_DNS_ZONES | Comma-separated zones the default provider holds. Optional; listing them is what enables the delegation and what makes bases that appear later work without a reload |
SCAFO_DNS_<FIELD> | One of the provider module's own JSON fields, lowercased: SCAFO_DNS_API_TOKEN is api_token |
SCAFO_DNS_<NAME>_PROVIDER | A named provider's module. Declaring this is what makes <NAME> a namespace |
SCAFO_DNS_<NAME>_ZONES | Comma-separated zones that provider holds |
SCAFO_DNS_<NAME>_<FIELD> | One of that provider's own JSON fields |
ZONES is reserved and cannot be a provider field name. A SCAFO_DNS_* variable with no provider declared anywhere is an error naming every one of them. Without SCAFO_DNS_<NAME>_PROVIDER, SCAFO_DNS_BN_API_KEY could not be told apart from a default-provider field called bn_api_key — declaring the name is what carves out the namespace.
#ACME and the image
| Variable | Description |
|---|---|
SCAFO_ACME_DELEGATION | The fixed name a delegated DNS challenge record is written at |
SCAFO_ACME_EMAIL | The contact address registered with the ACME CA. Optional |
SCAFO_ACME_CA | The ACME directory URL. Optional; unset uses Caddy's default |
SCAFO_ACCESS_LOG | Read by the image's default Caddyfile only: the access log file path. Default /dev/stdout. The log is JSON and the file mode is 0644 |
#The image's default Caddyfile
What the published image runs at /etc/caddy/Caddyfile, with its comments shortened. Mounting a Caddyfile of your own over it replaces it entirely; the module repository's examples/Caddyfile is the same file with the scafo block written out, and the place to start.
{
# Every fleet, DNS provider and ACME setting comes from the environment.
scafo {
}
# Certificates live in the bucket of the fleet that owns each name. The
# ACME account and Caddy's own housekeeping stay under the data directory.
storage scafo
# Only a name some fleet's routing document names exactly gets a
# certificate on demand.
on_demand_tls {
permission scafo
}
}
(scafo_log) {
log {
output file {$SCAFO_ACCESS_LOG:/dev/stdout} {
mode 0644
}
format json
}
}
(scafo_proxy) {
reverse_proxy {
dynamic scafo
lb_policy first
lb_retries 2
lb_try_duration 5s
fail_duration 30s
max_fails 1
}
}
(scafo_errors) {
handle_errors {
@missing vars scafo.route unknown denied
handle @missing {
respond <<HTML
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Not found</title></head>
<body style="font: 16px/1.6 system-ui, sans-serif; max-width: 32rem; margin: 12vh auto; padding: 0 1.5rem">
<h1 style="font-size: 1.25rem">Nothing is served here</h1>
<p>No deployment answers to <code>{http.vars.scafo.host}</code>.</p>
<p style="color: #666">scafo</p>
</body>
</html>
HTML 404
}
respond "{err.status_code} {err.status_text}" {err.status_code}
}
}
http:// {
import scafo_log
@plain scafo_plain_http
handle @plain {
import scafo_proxy
}
handle {
redir https://{host}{uri} 308
}
import scafo_errors
}
https:// {
import scafo_log
tls {
on_demand
}
import scafo_proxy
import scafo_errors
}#Operational notes
A node must be reachable when Caddy starts for its fleet to start already routing, but its absence does not keep Caddy from starting. A running Caddy is not affected by node downtime: TLS handshakes are served from the in-memory certificate cache, requests are routed from the last document that parsed, and only background work fails and is retried later — renewals, OCSP staple refreshes, a certificate for a new name, a route that has changed since the last successful poll — and only for that node's fleet.
A Caddy that starts while every node is down has an empty certificate cache and no routing document, so TLS fails and every request is unavailable until one answers:
- Start the nodes before Caddy and stop them after, so Caddy never outlives them.
- Compose: share a node's network namespace where there is one node, and
depends_onit. - systemd: order the Caddy unit
After=the node's, withRestart=on-failureandStartLimitIntervalSec=0. - Kubernetes: run a node as a sidecar in the Caddy pod with a startup probe.
Several Caddy instances may share one fleet's bucket; coordination relies on that node's lock endpoints, and expiring locks left behind by a crashed holder is the node's job. The ARI renewal lock is local to each ingress rather than shared.
#Rotating the token
The ordering rule: the node first, then Caddy. A Caddy reload refills the certificate cache from storage, so it must find a node that already accepts the token it is about to present. Serving is unaffected throughout, and only that fleet's renewals and route updates stall while Caddy holds a token the node rejects.
SCAFO_INGRESS_TOKEN="new,old", and restart it. Both are accepted.SCAFO_INGRESS_TOKEN="new" and restart it. The old token stops working.A single node and a single Caddy can skip the expand step: set the new value on both, restart the node, reload Caddy.