Applications

An application is the unit Scafo deploys, places, serves, blocks, retires and deletes. One application is one Celld fleet and one current deployment.

#The name

An application is identified by its name: 1–63 lowercase alphanumeric characters, optionally separated by single hyphens, with no leading, trailing or doubled hyphen. It is the same rule Celld applies to a config's name, and the limit is the DNS label limit, because the application is served at its name under every platform domain.

Its record lives at _scafo/applications/<name>.json.

FieldMeaning
v, nameIdentity. Never change; an update that tries raises immutable.
createdAtWhen the first deploy created it.
blockedtrue stops deploys, runtime, routing and certificates, deleting nothing.
labelsFleet-owned metadata, replaced wholesale and stripped from a non-admin's view.
deploymentThe deployment notice: revision and deployedAt. null until the first successful deploy, and it never names a version.
domainsCustom domains claimed for it.
wildcardDomainsThe subset of domains claimed with the single label below them. Absent when there is none.
replicasN or all; omitted resolves to every live node that may serve it.
nodeSelectorLabels a node must carry to serve it.
retiredAtSet by retirement; stops serving.

There is no create command and no create route. The first deploy that names an application creates it; every other setter refuses a missing one with not-found. Each mutation is a conditional read-modify-write that preserves the fields it does not own, so a deploy can never drop a block or the fleet's labels.

Note

A trust policy may exist before its application does: an admin pre-grants a name with scafo trust set, and the first deploy creates the application under it.

#Naming an application on the command line

Every command that acts on one application takes --name, and optionally --env:

scafo app show --name shop
scafo app show --name shop --env preview   # the application shop-preview

--env is a naming convenience and nothing else. It is resolved by the CLI, before anything is contacted: --name shop --env preview addresses the application shop-preview exactly as --name shop-preview would, and the node never sees an environment. shop and shop-preview are two independent applications that merely share a prefix — separate trust policies, separate placement, separate hosts, separate blocking, retirement and deletion.

Both halves must be names in their own right, and so must the joined result. A join that is too long for a DNS label is refused with invalid-slug naming both parts.

Read more in Reference > Cli#naming An Application.

#scafo app

scafo app list
scafo app show     --name shop
scafo app label    --name shop team=payments tier=gold   # admin only
scafo app block    --name shop                           # admin only
scafo app unblock  --name shop                           # admin only
scafo app retire   --name shop-preview
scafo app unretire --name shop-preview
scafo app selector --name shop --set region=eu-west-1
scafo app replicas --name shop --count all
scafo app domain add shop.example --name shop
scafo app delete   --name shop-preview
CommandEffectHow it reaches the installation
listThe applications this key reaches, and how many are blocked. --json prints the records.Node only
showOne application: state, creation time, deployment revision, replicas, selector, domains, labels. --json prints the record.Node only
blockStops deploys, runtime, routing and certificate issuance, reversibly. Nothing is deleted. Admin only.Node only
unblockPuts it back into service on the next pass of every node. Admin only.Node only
labelReplaces every label the application carries; no pairs clears them. Scafo stores labels and never reads them. Admin only.Node only
retireStops serving, reversibly.Node, or the bucket
unretirePuts a retired application back into service.Node, or the bucket
selector--set key=value, one pair per flag, constrains which nodes may serve it; --clear lifts the constraint.Node, or the bucket
replicas--count all, or a whole number of at least 1.Node, or the bucket
domainadd <host>, remove <host> and list: the custom hosts it answers on beside its platform host. --wildcard on add covers the single label below the host.Node, or the bucket
deleteDeletes a retired application, or any application with --force. Prints the keys it removed.Node, or the bucket

Every subcommand accepts --config, --remote, --node and --key. The ones that can also write the bucket directly additionally accept the bucket flags (--endpoint, --region, --bucket, --prefix, --access-key, --secret-key), which is what an installation with no node running has.

Listing, inspecting, blocking, unblocking and labelling go through a node on the authority of an operator key: they refuse with invalid-input when no node is named, rather than falling back to the bucket, because a bucket fallback would bypass the operator-tier authorization those calls exist for.

Warning

Deleting straight through the bucket takes the coordinator lease for the length of the deletion, which briefly leaves the installation without a coordinator. --node is the better route whenever there is a node to ask. If a live node holds the lease, the direct path refuses with lease-lost and says to ask the installation instead.

Note

A request touching a blocked application is refused with application-blocked (409). Its hosts leave the routing document, and with them every approval of a certificate for them.

#Hosts

An application answers on its name under every configured platform domain:

ApplicationHost under apps.example.com
shopshop.apps.example.com
shop-previewshop-preview.apps.example.com

The host is the name, and the name is what a DNS label can hold. A name too long for one is skipped rather than failing every other route.

Platform domains are configured on the node with --platform-domains / SCAFO_PLATFORM_DOMAINS / platformDomains. An installation with none serves no application hosts.

#Custom domains

An application also answers on hosts of your own, claimed for it one at a time:

scafo app domain add    shop.example --name shop
scafo app domain add    shop.example --name shop --wildcard
scafo app domain remove shop.example --name shop
scafo app domain list   --name shop

A claim is written at _scafo/domains/<host>.json and mirrored into the application's domains array, with wildcardDomains naming the subset that covers its subdomains. Routing publishes each claimed host alongside the platform hosts while the application is serving, and a host the routing names is a host that may get a certificate.

Platform hosts always win: routing writes application hosts first, and a claim can never shadow one — a host under a platform domain cannot be claimed at all. A host already claimed by another application is refused with reference-held. Claiming is an operator's act and never a deploy pipeline's.

Read more in Docs > Custom Domains.

#Labels and selectors

An application's nodeSelector narrows which nodes may serve it. It is AND over exact values: every entry must match a label the node carries. A node label the selector does not mention is ignored.

scafo app selector --name shop --set region=eu-west-1 --set tier=fast
scafo app selector --name shop --clear

Exactly one of --set and --clear must be given. Both together, or neither, is invalid-input: they state opposite intentions, and an accidental bare scafo app selector that lifted a constraint would move an application across the fleet without anyone asking.

Read more in Docs > Placement.

#Retirement and deletion

scafo app retire   --name shop-preview
scafo app unretire --name shop-preview
scafo app delete   --name shop-preview
scafo app delete   --name shop-preview --force
StepBehaviour
RetireStops serving: the application leaves the routing document and its processes stop on the next pass of every node. Reversible. Retrying does not restamp the grace.
Grace24 hours from retiredAt.
DeleteAllowed for a retired application whose grace has passed, or with --force. Coordinator-gated: a node that does not hold the lease forwards the request to the one that does. Prints the keys it removed.

Deleting removes everything of the application: its record, its trust policy, its deployment records, its deploy records, its placement, its domain claims and the whole fleets/<name>/ prefix Celld owns.

#Production and preview

--env exists for one pattern: one application per deployment target, named by a shared prefix. Two CI jobs deploy two independent applications out of the same repository.

# .github/workflows/deploy.yml
jobs:
  production:
    if: startsWith(github.ref, 'refs/tags/')
    steps:
      - run: scafo deploy ./dist --node https://apps.example.com --token "$OIDC_TOKEN"

  preview:
    if: "!startsWith(github.ref, 'refs/tags/')"
    steps:
      - run: scafo deploy ./dist --node https://apps.example.com --env preview --token "$OIDC_TOKEN"

Neither job passes --name: the CLI reads name from the packed directory's wrangler.jsonc, which says shop. The tagged job deploys shop; every other commit deploys shop-preview.

Each application carries its own trust policy, each exact to its own application and its own git ref:

scafo trust set --name shop \
  --issuer https://token.actions.githubusercontent.com \
  --subject 'repo:acme/shop:ref:refs/tags/*'

scafo trust set --name shop --env preview \
  --issuer https://token.actions.githubusercontent.com \
  --subject 'repo:acme/shop:ref:refs/heads/*'

The production policy admits tag pushes and nothing else; the preview policy admits branch pushes and nothing else. Neither can reach the other's application, because a policy belongs to one application exactly.

The two are served at two hosts:

ApplicationHost
shopshop.apps.example.com
shop-previewshop-preview.apps.example.com

An operator key scoped --apps 'shop-*' reaches shop-preview and not shop, so a key that may touch previews cannot touch production.

Note

A rollback is a redeploy of the older build. Celld serves one current deployment per fleet, so Scafo has no historical deployment selector: run scafo deploy again from the build you want serving.

#Variables

An application's variables live in its own wrangler.jsonc or wrangler.json, under vars. That config is the runtime's only variable channel: the node hands the artifact's config to Celld untouched, so whatever vars says at deploy time is what the application reads.

scafo deploy --var KEY:VALUE sets a variable for one deploy. The flag is repeatable, the pair is split at the first colon so a value may contain colons, and an empty value sets the variable to nothing rather than leaving it unset. A name given twice is refused with invalid-input. Names are letters, digits and underscores, never beginning on a digit, and never __proto__, constructor or prototype.

A --var entry replaces the config's own vars entry of the same name and leaves every other entry as written. The edit is made by the CLI, in memory, inside the uploaded archive only — the file in the project directory is read and never written.

// wrangler.jsonc — defaults, committed with the project
{
  "name": "shop",
  "main": "dist/index.js",
  "compatibility_date": "2025-01-01",
  "vars": {
    "LOG_LEVEL": "info",
    "DATABASE_URL": "postgres://localhost/shop"
  }
}
# CI: keep the defaults, override the one value that differs per application
scafo deploy --name shop --var DATABASE_URL:"$DATABASE_URL"

LOG_LEVEL stays info; DATABASE_URL becomes whatever the CI secret holds.

Warning

A value passed with --var is visible in the process list and the CI log of the machine running the CLI, and Celld stores variables as plain text in the bucket — anyone with bucket read access can read every one of them. Celld has no secret store. Keep defaults in the config's vars, pass the values that differ from the CI's secret store as --var, mask them in CI logs, and treat bucket read access as access to every variable.

#Trust policies for workload deploys

A trust policy lets a CI workload deploy an application with its own OIDC token instead of an operator key. It lives at _scafo/trust/<name>.json, and it is exact: one policy per application.

scafo trust set --name shop \
  --issuer https://token.actions.githubusercontent.com \
  --subject 'repo:acme@123456/shop@456789:ref:refs/heads/main'
scafo trust add    --name shop \
  --issuer https://token.actions.githubusercontent.com \
  --subject 'repo:acme@123456/shop@456789:ref:refs/tags/*'
scafo trust show   --name shop
scafo trust remove --name shop --issuer https://token.actions.githubusercontent.com

set states the whole list of subjects an issuer is trusted for, so it is also how one is taken away: state the list without it. add keeps the list and appends to it. remove stops trusting the issuer altogether.

FlagMeaning
--nameThe application the policy belongs to. Required. --env addresses <name>-<env> instead.
--issuerIssuer URL, exactly as its tokens carry it in iss. Required.
--audienceAudience the workload requests its token for. Defaults to scafo.
--subjectSubject pattern to trust; * matches any run of characters. Repeat the flag for several; each occurrence is one subject and nothing is split on commas. Required.

scafo trust writes the bucket directly, because a policy is usually set up before there is a node to ask. It takes the bucket flags and --config.

A GitHub subject comes in two forms — the plain repo:OWNER/REPO:ref:... and the immutable repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:... above — and which one a repository carries is covered under Deploying from CI with OIDC.

A token that verifies but matches no policy entry is refused with trust-mismatch. A bad, expired, forged or unknown-key token is auth-invalid.

Read more in Docs > Deploying.

#Deployment revision

Each successful deploy advances the monotonic deployment.revision on the application record, alongside a deployment.deployedAt stamp. It is a notice, not a selector: Celld exposes one current deployment per application fleet, and Scafo provides no per-deployment host and no historical deployment selection.

The deploy itself is recorded twice. The deployment Celld published gets an immutable record at _scafo/deployments/<name>/<version>.json, filed under the version Celld assigned and nothing else. The run that produced it gets a DeployRecord at _scafo/deploys/<name>/<name>.<16 hex>.json, which is where its state lives until it finishes.

Earlier deployments stay in the application's Celld fleet and nothing routes to them; scafo deployment prune is what deletes them.

Read more in Reference > Cli#scafo Deployment Prune.