Imported from skyr-cloud/agent-plugin (
skills/deploy/SKILL.md). Install upstream withnpx skills add skyr-cloud/agent-plugin --skill deploy. Copyright stays with the author.
Deploying to Skyr
Skyr is a Git-native infrastructure orchestrator: a repository of SCL
configuration is the deployable unit, and pushing it to a Skyr git remote
is the deployment action. There is no separate plan/apply step and no
deploy command — Skyr converges reality to whatever the pushed commit
declares. This skill describes how that works: the lifecycle, what the
first-party plugins can build (with complete examples), and how to observe
and debug a rollout. Authoring the SCL itself — syntax, types, modules,
Package.scle — is the scl skill's territory.
Examples use skyr.foo as the instance host; if the user's repository
deploys to a different Skyr instance, substitute that instance's host.
Mental model
- The hierarchy is Organization → Repository → Environment → Deployment.
An environment is a git branch or tag (
main,tag:v1.0); a deployment is one commit of that environment. Deployment QIDs spell the whole chain:acme/shop::main@a10fb43f…(/org/repo,::env,@commit). git push skyr mainis the deploy. Skyr evaluatesMain.sclat the repo root, builds the resource dependency graph, and creates/updates/destroys resources until reality matches. Pushing a new commit to the same branch rolls the environment forward; resources shared between old and new config are adopted (ownership transfers), not recreated.- An edge comes from using another resource's outputs — either building a
resource's inputs from them, or gating its declaration on them
(
if (job.exitCodes["job"] == 0) Container.Pod({...})). There is nodepends_on; both kinds are worked out for you, and SCL'swith (a) B({...})writes the edge explicitly where neither flow carries it (thesclskill covers it). Edges order creation and, reversed, teardown: nothing is destroyed while something depending on it is still there. - Every branch environment has two refs.
ais the environment itself;deploy/ais where a deployment is pushed for approval — evaluated continuously against live state, applying nothing, until somebody approves it (see the approval section below).deploy/afollowsawhenever nothing is pending, so after agit fetch skyrthe diffskyr/a..skyr/deploy/ais exactly what is under review, and empty when nothing is. Branch names underdeploy/— and the bare namedeploy— are reserved and cannot be environments; tags have no proposal ref. - Deleting a ref tears the environment down:
git push skyr --delete feature-xdestroys everything that environment owns, in dependency order. A durable resource —PersistentVolumeis the first-party one — is the exception: its destruction is held until a person approves that particular deletion, and the teardown does not finish until somebody does (see "Durable resources: deletion waits for a person"). - Rolling back is a first-class operation, not a git manoeuvre: every
deployment records which one preceded it, and
skyr deployments rollback <env>redeploys that predecessor's commit as a new deployment. A deployment with no recorded predecessor is inert, and rolling it back tears the environment down — see the rollback section below before running it. - Deleting a whole repository, organization, or account goes further than a
ref:
skyr repo delete,skyr org delete <org>, andskyr auth delete-accounttear down every environment involved, then permanently erase the entity's data — one-way, no undo, gated behind typing the entity's exact name to confirm. The entity stays visible with adeletingstatus until the purge finishes; whiledeletingit accepts reads but no pushes or other changes. Deleted user and org names are retired for good; a deleted repository's name frees up for reuse. - Every resource has a region (a metro label like
"stockholm"), part of its identity, defaulting to the repository's region. Region-placeable resources take aregion: Str?input; changing it is destroy + create.
Wiring a repository
Deployment needs three things: the CLI (for inspection, not for deploying), an authenticated identity, and a git remote pointing at the instance.
-
CLI:
curl -fsSL https://dl.skyr.cloud/install.sh | shinstallsskyrto~/.local/bin.skyr --versionconfirms. -
Identity is a Skyr username plus a registered SSH key — the same key authenticates both git pushes and CLI sessions.
skyr auth whoamishows the current session;skyr auth signin --username <u> --key <path>starts one.skyr auth signup --username <u> --email <e> --region <r>creates a brand-new account and registers the key — that mints a real account, so whether (and as whom) to sign up is the user's decision, not a setup step to run through. -
Org and repo exist server-side before the first push:
skyr org create acme --region stockholm, thenskyr repo create shop(region defaults to the org's; a--deployment-roledefaults to the org's all-accessSuperrole). -
Remote: plain SSH at the instance host, authenticated by the registered key, with your username as the SSH user — the scp-style remote that
skyr repo createprints and the repository's web UI page shows (anssh://<user>@<host>:<port>/<org>/<repo>URL instead when the instance's SSH endpoint is on a non-default port, which scp-style syntax cannot carry):git remote add skyr alice@skyr.foo:acme/shop git push skyr main
The CLI infers org/repo from the skyr remote (falling back to origin)
and the environment from the current branch. Everything can be overridden
with --org/--repo/--env flags or SKYR_ORG/SKYR_REPO/SKYR_ENV/
SKYR_API_URL env vars, and any command takes --format json for
machine-readable output.
The deployment lifecycle
A deployment moves through these states:
- Proposed — pushed for approval, not the environment's head, owning no
resources. Compiled, tested and evaluated against live state on the ordinary
cadence, but every effect it works out is recorded as a plan for a reviewer
instead of being applied: nothing is created, updated, adopted or destroyed
while a deployment is Proposed. One per environment. It leaves the state either
by being approved — the same deployment becomes Desired — or by being discarded
straight to Down, since it owns nothing to tear down. Listed as
PROPOSED, held on somebody's decision. See the approval section below. - Desired — actively reconciled: evaluate config, create/update/adopt resources. Most deployments start here.
- Up — converged and every resource is non-volatile; Skyr stops re-evaluating until the next push.
- Lingering — superseded by a newer push; waits while the new deployment
rolls out and adopts shared resources. It keeps serving until the successor
converges, and not converged includes a resource the successor cannot yet
declare — one whose inputs are still pending — not just one it is still
creating. Being unable to state a resource is not the same as no longer
wanting it, so the predecessor keeps it alive rather than the successor
converging around the gap. The deployment log names each such resource
(
Waiting to declare <resource>: one of its inputs is still pending, once per stuck declaration, and only on a pass with nothing else in flight — while resources are being created, a declaration waiting its turn is ordinary ordering). Where Skyr can tell which read has not resolved, the line ends by naming it (… (waiting on <resource>)) — the resource to go look at. Where it cannot, the line falls back to listing everything the declaration reads (… (it reads <resource>, <resource>)), which is a hedge rather than a verdict: the culprit is one of them. A resource whose name is itself derived from another resource's output holds the rollout the same way, and is named by type while it waits (Waiting to declare a <type> resource: its own identity is still pending). A branch the successor cannot decide holds the rollout the same way, and for the sharper version of the same reason: a resource declared inside anifwhose condition is still pending is never even attempted, so there is no declaration to name. The log says where the program stopped short instead (Waiting at <construct> in <module> (<span>): a pending value settled it, so a region that could declare resources went unexplored), naming the reads it is stuck behind on the same terms as a declaration's line. One unreadable value is one line however many regions it kept the pass out of — a gate in a frontend, theifinside the helper that gate calls — because they resolve together. A branch that could not have declared anything is not a hold at all: picking between two strings leaves nothing undeclared, so a?? pendingfence over a value you only interpolate is silent. Both rules cover every region a pending value skips rather than decides, not only a branch: an operator's right-hand side, an index expression, and the parts of an interpolated string after a pending one are held when they declare and silent when they do not. A declaration whose reads have settled is a dead end — nothing the deployment is doing can resolve it — and says so at warning severity whatever else is going on (Stuck declaring <resource>: … and nothing in flight can resolve it;Stuck at <construct> in <module> …for a branch), followed once by what the hold costs: while it lasts, resources removed from the program are kept rather than destroyed. Changing the program and pushing is the way out; the deployment keeps checking and repairing what it already owns in the meantime, and opens no incident. A stall is a hold, not a failure, and it is carried on the deployment as well as in the log —skyr deployments listcounts holds in aHELDcolumn and spells each one out beneath the table, and the API answers them onDeployment.status.holds. That is the surface to check when the log has scrolled past the moment the hold began. The same list carries the other waits that leave a healthy deployment unfinished: an unset, defaultless knob; a read of a resource another deployment has yet to create or update; and an unset knob in another deployment's environment, which waits on a person you may not be. Each names who is expected to clear it, and only the stalled declaration has nobody. The dead-end test asks the narrower set wherever Skyr has one: a declaration waiting on a read that is already final is stuck even while unrelated work is in flight. Where it cannot say which read it waits on, every resource the declaration reads has to have settled before the line is earned. Do not expect theStuck declaringline for every dead end: a declaration deferred on a pending that reaches it with no resource behind it at all — a bareStd/Plugin.pendinga frontend handed over without joining it to a read — keeps the ordinaryWaiting to declareline, because Skyr cannot tell that apart from a value still on its way. A rollout that is not converging with only waiting lines in the log is that case; read the program rather than waiting for a warning. - Undesired — teardown: resources not adopted by the successor are destroyed in dependency order. A durable resource holds this short of Down: its destroy waits for a person to approve it, and the deployment carries a hold saying so while everything else comes down around it.
- Down — nothing left; terminal.
A commit's own tests run before anything is applied. When the repository
declares any (the scl skill covers writing them), Skyr runs them once for the
commit — ahead of evaluating Main.scl at all, so a commit whose tests fail
creates, updates and destroys nothing. On a push that means the deployment it
would have replaced keeps serving untouched: the successor never adopts a
resource and never starts the predecessor's teardown, so the old one sits in
Lingering. On a fresh environment it means nothing is created. The blocked
deployment stays Desired and retries on the ordinary backoff, still
maintaining the resources it already owns — blocking withholds new work, never
maintenance — until a commit whose tests pass supersedes it. skyr test runs
the same cases locally and is what predicts the verdict; a repository with no
test code is unaffected.
Two things legitimately keep a deployment in Desired forever — that is normal operation, not a stuck rollout:
- Any volatile resource (a
Container.Pod,DNS.Zone, or anHTTP.Resourcewith acheckrepresents external state that can drift, so Skyr keeps reconciling). - A branch or tag pin in
Package.scledependencies: the deployment keeps following the foreign ref. Pin commit hashes to settle.
A deployment of only stable resources (keys, random values, artifacts, images) settles into Up.
Pushing for approval
A push to deploy/<env> proposes a deployment instead of deploying one: the
environment's head does not move, and the proposal is evaluated continuously —
same compile, same test gate, same reads of live resource state — recording what
it would do rather than doing it. Approving promotes that same deployment.
git push skyr HEAD:deploy/main # propose
git fetch skyr # then, the change under review:
git diff skyr/main..skyr/deploy/main
git push skyr :deploy/main # withdraw it
skyr deployments approve main # or: reject main
- Two verbs, and neither implies the other.
environment:PushForApprovalon the environment gates any push todeploy/<env>, withdrawal included — deliberately notenvironment:Delete, since nothing real is deleted.environment:ApproveDeploymentgates both approving and rejecting, and implies neither push nor delete authority. A first push throughdeploy/…into an environment that does not exist yet pairs its verb withenvironment:Create, as a first direct push does. - Protection is IAM, not a setting. There is no protected-environment flag
and nothing badges one. An org protects
prod-*by grantingenvironment:Pushonacme/shop::prod-*to nobody (or one release role) while grantingPushForApprovalbroadly, andApproveDeploymentto reviewers. Grants only add — there are no deny rules — so a wildcardenvironment:Pushonacme/shop::*elsewhere undoes it, and the verb matcherenvironment:Push*grants both verbs.Superbypasses all policy and can always push directly; the expressible posture is narrow day-to-day roles plusrole:Assumefor the wide one. Self-approval is allowed — four eyes means not granting both verbs to one role. Note thatPushForApprovalis not read-only: a proposer's objects are unpacked into the repository's object store even if the proposal is never approved. - What discards a pending proposal: any promotion of the head (a direct push, the API's deploy/commit mutations, a rollback, or the approval itself), a newer proposal, a rejection, a withdrawal, and deleting the environment — provided that deletion actually retires a deployment. Discarding goes straight to Down; a proposal owns nothing to tear down.
- The review surface. The proposal's resource graph is the plan: nodes are certain (what approving does) or merely possible (a region fenced behind a pending value), and each says which transition it would make — create, update, keep, or destroy — with an empty set meaning "not decided", never "nothing happens". The plan can be clipped for size or have parts withheld for permission, and either voids any conclusion drawn from what is absent: while one is true, a resource missing from the plan is not one the proposal would leave alone. An empty plan means the pass never got as far as evaluating (compile error or failed tests), or has not reported yet, or the configuration declares nothing and there is nothing live for approving to strand — never "this does nothing".
- The plan also says what approving would delete. A promoted deployment adopts what it declares and nothing else, and whatever the deployment it replaces still owns is torn down behind it — so the plan draws every resource the environment holds now that the proposed configuration names nowhere, as the resource it is with a destroy on it. That is deletion by absence, and which kind of node carries the destroy is what tells the two apart: on a declaration it is one the configuration asked for, on an existing resource it is the opposite — nothing asks for it, and the deletion is the whole of what the plan's silence about it amounts to. The review counts them apart from declared destroys and hedges the unsure ones ("to delete", "may be deleted"), and draws them in the graph with their dependency edges, so a doomed subtree hanging off a resource that stays reads as one.
- A deletion is certain only where the plan can be believed silent. No declaration names the resource, so the reading is decided by the plan — and, in one case, by the resource's own type: nothing clipped for size, no region left unexplored, and every declaration whose name is still unsettled ruled out by its type. Clipping or an unexplored region hedges every deletion; an unsettled name hedges only the resources whose type it could still turn out to name, so certain and hedged deletions sit side by side in one plan. A configuration declaring nothing at all is the extreme: it reads as the whole environment marked for deletion, the most destructive approval there is — but only where the last report succeeded and is at least as recent as the plan, which is what keeps the equally empty plan a failed pass leaves behind from ever reading that way. A vouching report can lag by a pass, and in that window the same configuration shows as "No declarations" with no teardown on it; the next pass settles the vouching. And a plan that could not be read at all arrives empty and clipped, which is neither of those: it strands nothing, and is reported as clipped rather than as declaring anything.
- What the deletions leave out. Sticky resources are never among them:
they are marked to outlive whoever declared them (
Artifact.Fileis one), so being left unnamed is no fate for them. A durable resource is named, but approving the deployment does not destroy it: the destroy that follows is held for an approval of its own (see "Durable resources: deletion waits for a person"). And the set is a floor on both surfaces, the counts and the graph alike — it is filtered by yourresource:Viewone resource at a time, and one you may not see is dropped with no flag saying so, since that it exists at all is exactly what a denial withholds. The review's warning that part of the plan names resources you may not see is about what the plan names; its absence promises nothing about the deletions being complete. - The verdict does not block the decision. A proposal that fails its tests or
does not compile opens the usual
TestFailure/BadConfigurationincident and can still be approved; approving clears none of it. That is safe because approval skips nothing — the promoted deployment runs the same gate again, and a commit whose tests fail materializes nothing while the predecessor keeps serving. Read the verdict anyway: approving a failing proposal buys a held rollout. - Decisions bind to one exact deployment. The CLI resolves the environment's
pending deployment, prints the commit, what deciding does, the holds and any
open incident, then makes you type the environment's QID (
--yesstates it up front, and is required with no terminal). A newer proposal landing in between makes the decision refuse, saying what became of the one you decided about and to go and read the pending one. Both decisions are gated, unlike a rollback's restoring outcome, because the operand is somebody else's commit and is never in argv. - An environment can be born proposed — a first push to
deploy/newenvgives it adeploy/newenvref, no head, and nothing running. It exists (it is listed, it has a page) but has no current deployment. Clearing it takes a withdrawal, a rejection, or an API teardown: there is nonewenvref forgit push skyr :newenvto delete, and a deletion that retires no deployment discards no review, soenvironment:Deletealone cannot clear it over Git. - Nobody is notified. A waiting review is found by looking — the
environment's page, or
skyr deployments list, where a proposal is an ordinary row in statePROPOSEDwith its hold (itsRESTOREScell is blank: rollback lineage is recorded at promotion, and a proposal cannot be rolled back).
Durable resources: deletion waits for a person
Some resources hold data nothing in a configuration can put back, and those are
durable: Skyr never destroys one on a reconcile pass's own authority.
Container.PersistentVolume is the first-party durable type — its contents are
the volume. Durability is a per-resource marker, answered on every transition
like volatility and stickiness, so it is a fact about the resource rather than
about its type; the web shows it as a marker on the resource. A volume that
should not be held that way declares deletionProtection: false, and is then
destroyed like any other resource; flipping the field is an ordinary in-place
update that leaves the data alone and, like every update, withdraws a request
already waiting on the volume. skyr resources list carries no durability marker — what it shows is where a deletion has got
to: a DELETION column reading PENDING or APPROVED, empty for a resource
holding none (--format json carries a deletion_approval object).
-
What a held destroy looks like. The first
Destroy— from a teardown, from dropping the declaration, or fromskyr resources delete— is recorded as a request and stops there. Nothing is destroyed, the resource's log saysAwaiting approval to destroy, and everybody whose role may approve it is notified. Where the destroy came from a deployment — a teardown, or a program that dropped the declaration — that deployment keeps re-asking every pass and carries a hold:skyr deployments listcounts it inHELDand spells it out ("Waiting for the deletion of<resource>to be approved …"), and the deployment page shows it under Health. That hold is why an Undesired deployment sits without reaching Down; it is not a failure and opens no incident. Askyr resources deleteof a resource its deployment still declares is one request rather than a standing one — nothing re-asks for it and no hold is reported — but the request stands until somebody approves it or the resource is reconciled again. -
How to approve it.
skyr resources approve-deletion stockholm:Skyr/Container.PersistentVolume:data skyr resources approve-deletion Skyr/Container.PersistentVolume:data --yesIt prints what is held (resource, environment, when it was first held, the declaring deployment) and makes you type the resource's full QID back;
--yesstates that up front and is required with no terminal. It refuses, sending nothing, when the resource is not there, when nothing is awaiting approval, and when the deletion was already approved — naming that first approver and when. The web has the same decision on the resource page and on the resource list; the API isapproveResourceDeletion(resource: <QID>). -
Approving and deleting are separate permissions. Approving needs
resource:ApproveDeletionon the resource; it lifts the hold and destroys nothing, and it implies noresource:Delete. Soskyr resources deleteon a durable resource has to be run again after approval — the first run only requested it, the second one carries it out under the delete permission. (The web does that second call for you when the approver holds both; the CLI does not.) An approval is recorded once and never re-stamped — a second approver is told who decided it and when — and it outlives what it released: the tombstone says who approved the deletion and when. -
Withdrawing is declaring it again. Any non-destroy transition — create, update, adopt, check — clears the request, approved or not, so pushing a configuration that still declares the resource is how to keep it. A deployment merely continuing to run withdraws nothing: an unchanged, non-volatile resource is given no transition at all, so it takes a push or a changed declaration. A later destroy needs a fresh approval; the cleared one is not redeemable.
-
It still works during a repo or org deletion.
resource:ApproveDeletionis in the delete family, so the freeze adeletingentity is under lets it through — otherwise the cascade would wait forever on a decision nobody was allowed to make. It is never grantable toAnonymous.
Rolling back
Every deployment Skyr promotes records its rollback target: the deployment that was current immediately before it. A rollback redeploys that target's commit as a fresh deployment — no old deployment is revived — through the same compile → test → evaluate → converge path as any push.
skyr deployments rollback main
skyr deployments rollback main --reason "checkout error rate spiked after 3f2a1c"
skyr deployments rollback main --reason-file ./why.txt # `-` reads stdin
- It is ordinary safe supersession. The deployment being rolled back goes Lingering and keeps serving until the rollback deployment bootstraps. If the restored commit no longer compiles or its tests now fail, nothing is torn down and the serving deployment carries on. Pushing again recovers.
- Rollbacks compose. A rollback result takes its commit from the target but
inherits the target's own target, so repeated rollbacks walk back one
deployment at a time. Deployed
A -> B -> C: rolling backCgivesB'(whose target isA), rolling backB'givesA'(no target). - A deployment with no target tears the environment down — the one dangerous
outcome here. It does not fail and does not no-op: it destroys every resource
in the environment, exactly as
git push skyr --deletewould. Three kinds of deployment have no target: an environment's first one, a rollback result that restored such a first one, and any deployment created before the instance recorded rollback lineage (it is never backfilled, so one ordinary push is what gives such an environment a target again). Check before acting:skyr deployments listprintsnone (teardown)inRESTORESfor an inert deployment. The CLI prints the consequence and then makes you type the environment's QID for that outcome (--yesstates the intent up front, and is required with no terminal to prompt on — a piped stdin, or one already spent on--reason-file -). The restoring outcome is deliberately ungated. The git ref survives a teardown, so pushing re-creates the environment. - The request binds to one deployment. The command takes an environment but resolves its current deployment and submits that exact one, so a push landing in between makes the rollback refuse and name what is current now. Retries are safe: the plan is recorded durably before anything happens, so a repeat, a crash, or a racing request all resolve to that same rollback.
- Reasons are kept verbatim as permanent provenance, newlines included: at
least one non-whitespace character, at most 512 characters.
--reasonand--reason-fileconflict. Omitted, the recorded default names the asker —Manual rollbackfor a person,Rollback condition reachedforRollout.Rollback. - Permission is
environment:Rollbackon the environment, covering both outcomes, teardown included. It implies neither push nor delete authority and is never anonymous. A rollback from config runs on the deployment role, which must be granted the verb explicitly. - History answers all of it and
skyr deployments listprints it:RESTORES(target, ornone (teardown)),ROLLED BACK(-> <id>ortorn down),ROLLBACK OF(this row is itself a rollback result), plus each reason verbatim beneath the table. Two reading rules:RESTORESis lineage, not an offer — it is filled in for Lingering and Down rows too, and only the current deployment can actually be rolled back; and a successor id is reserved before its row exists, so a reference to it can briefly resolve to nothing and is expected to appear on the next poll.
From the config itself. Rollout.Rollback is the same operation as a
resource, so a deployment can roll itself back:
import Skyr/Rollout
// Whatever your code decides from — a knob, a version, or a pod's own
// `health` output (`.degraded`/`.failing` once that pod has worked at least
// once; pending before then, so a condition over it simply waits).
if (degraded)
Rollout.Rollback({ reason: "Health check remained degraded" })
else
nil
Declaring it is the request; there is nothing to read off it, and whether to
roll back is ordinary control flow rather than an input. The record is required
even when empty — Rollout.Rollback({}); Rollout.Rollback() does not compile,
since SCL has no omittable arguments. Identity is this deployment plus the
reason and nothing else, so: the same reason twice in one deployment is one
request; two different reasons are two requests that the platform serializes to
one recorded rollback; a reconcile retry asks for the same one; and the same
reason in a later deployment is a new request — a condition still true after
the rollback rolls back again, one step further. Every attempted reason lands in
the deployment log even when it loses the race or is refused. The deployment role
needs environment:Rollback, granted in the repository's own IAM.Policy.
This is the surface with no interlock, unlike the CLI and the web dialog: it
asks nobody and runs on a reconciliation pass, so if the declaring deployment has
no rollback target the declaration tears the environment down unattended. Check
the environment's RESTORES before wiring one up, and note that each rollback
leaves a deployment whose own target is one step further back — a condition still
true afterwards walks the lineage down to the inert one.
From a pod to a public domain
The complete path from "a container runs" to "https://example.com serves it". Each stage is deployable on its own; later stages extend the same file.
Run a container
import Skyr/Container
import Std/Path
let image = Container.Image({
name: "web",
context: ./app,
containerfile: Path.read(./app/Containerfile),
})
let pod = Container.Pod({
name: "web",
containers: #{ "web": {
image: image.url,
cpu: 500, // millicores; hard limit and reservation
memory: 268435456, // bytes (256 MiB); hard limit and reservation
} },
})
Image builds from a directory in the repo and pushes to the instance
registry; image.url is digest-pinned. Public images ("caddy:2") work
directly as image values. containers is a name-keyed map
(#{ "name": { ... } }); the key names the container in logs and, for
containers that can finish, in the pod's exitCodes output. cpu and
memory are required on every container — both are hard limits. Containers in the same pod share a network namespace, so
siblings reach each other on localhost.
Running something other than the image's default. A container starts its
image's own ENTRYPOINT/CMD unless it says otherwise. The optional
execute field replaces exactly one of those two halves — .arguments([…])
keeps the entrypoint and replaces the CMD (the flag-passing case, which needs
an image that has an ENTRYPOINT: on a CMD-only image the first argument is
exec'd as the program and the container dies), .command([…]) replaces the
entrypoint and drops the CMD (the whole command line — one image serving both
a service and a migration, and the right choice for a CMD-only image). Nothing parses a
shell, so a pipeline is .command(["/bin/sh", "-c", "a | b"]), and an empty
argv is rejected. An argv takes no .secret(…) and is stored, logged and
hashed verbatim — pass credentials through env. workingDirectory: Str?
replaces the image's WORKDIR and must be an absolute path; a tenant
container's rootfs is read-only (unless it sets privileged: true — see
Privilege under Other capabilities), so the directory must exist in the image
or be one of its mount paths. Both are part of the pod's identity, so changing
either recreates the pod.
containers: #{ "migrate": {
image: image.url, // the same image the service runs
cpu: 500,
memory: 268435456,
execute: .command(["/app/bin/migrate", "--yes"]),
workingDirectory: "/app",
restart: .onFailure,
} }
Narrowing the build context. Everything under context goes to the
builder, so a COPY . . bakes in whatever is lying around — node_modules, a
stray .env, a local build directory. The optional ignorefile input excludes
paths, and like containerfile it takes the file's content, not a path to
it: ignorefile: Path.read(./app/.containerignore), or the patterns written
inline. The syntax is Docker's and podman's, not gitignore's — one pattern per
line, # comments, anchored at the context root (node_modules excludes the
top-level entry only, **/node_modules excludes it at any depth), * and ?
never crossing a /, and a leading ! negating with the last matching
pattern deciding (* followed by !dist ships dist/ and nothing else).
There is no filename convention: a .containerignore sitting in the context is
an ordinary file until something passes it as this input, so the patterns may
live anywhere or come from any file. Excluded files never reach the builder at
all, and a line that isn't a valid pattern fails the build naming it.
Open ports — how ingress works
Every pod gets a public, internet-routable IPv6 (pod.address). By
default it is inert for ingress: the pod's firewall only accepts connections
from internal networks the pod is attached to, and the IPv6 serves egress.
Until the backend has allocated it, pod.address reads as pending, so
resources built from it defer. Opening a port changes that:
let http = pod.Port({ port: 8080, public: true })
// http.address is "[<pod-ipv6>]:8080". Until the pod's address has been
// allocated it reads as pending, so resources built from it defer.
public: true opens the port to the internet — both on the pod's IPv6 and
on any bound IPv4 address (next section). Without it the port only accepts
traffic from attached internal networks.
protocols defaults to [.tcp]; .udp is the other member. One call is one
opening, so a service that shares a port number across transports names them
together rather than declaring the port twice:
pod.Port({ port: 443, protocols: [.tcp, .udp], public: true }) // HTTP/2 + HTTP/3
pod.Port({ port: 53, protocols: [.tcp, .udp] }) // DNS
Order and repeats don't matter — the opening is the set — and an empty list is refused rather than treated as the default.
A stable public IPv4
The pod's IPv6 changes when the pod is replaced (its identity hashes its
configuration). For a stable entry point, allocate an InternetAddress and
route it to the pod:
let ip = Container.InternetAddress({ name: "front-door" })
let pod = Container.Pod({
name: "web",
containers: #{ "web": { image: image.url, cpu: 500, memory: 268435456 } },
internetAddress: ip,
})
ip.address is a public IPv4 that survives pod replacement and redeploys.
Like pod.address it reads as pending until the allocation lands.
An address routes to exactly one pod at a time, anywhere in Skyr — two
pods in one program naming it is an eval-time error, and a pod elsewhere
trying to take an address another pod already holds fails the deploy, naming
the holder. (Replacing a bound pod is fine: the successor in the same
environment takes the claim over.) The claim is released when the bound pod
stops naming the address or is deleted. Binding needs
resource:BindInternetAddress on the address, and the pod must be in the
address's region. The pod's firewall still applies: only public: true ports
accept internet traffic on the address.
Serve public traffic on IPv6. A pod's public entry point is its IPv6
address, and a bound InternetAddress (public IPv4) is delivered to the pod
translated to that IPv6. So a workload that must be reachable from the internet
has to listen on IPv6 — bind [::] (or dual-stack), not only 0.0.0.0. A
process listening on IPv4 0.0.0.0 alone will not receive public traffic,
including traffic to a bound InternetAddress.
A custom domain
Skyr/DNS serves authoritative DNS for user-owned domains:
import Skyr/DNS
let zone = DNS.Zone({ domain: "example.com" })
zone.ARecord({ name: "@", addresses: [ip.address] })
zone.AAAARecord({ name: "@", addresses: [pod.address] })
zone.CNAMERecord({ name: "www", target: "example.com" })
- The zone's
nameserversoutput lists four hostnames under the instance apex. Delegating the domain to them at the registrar is what makes Skyr authoritative — the delegation itself is the proof of ownership. The volatilestatusoutput reports what public resolvers see as an enum atom:.delegated,.partial,.wrong, or.pending. - Record names are relative to the apex:
"@"for the apex,"*"for a wildcard. Available types:ARecord,AAAARecord,CNAMERecord(not at the apex),ALIASRecord(apex-safe server-side alias),TXTRecord,MXRecord,SRVRecord,NSRecord(sub-delegation),CAARecord. - Bad inputs raise
DNS.InvalidDnsInputat evaluation time: a name outside the grammar, a CNAME or NS at the apex, a CAAflagsoutside 0–255, and attl/defaultTtlthat is not a whole number of seconds from 1 to 2147483647 — which rules out anything sub-second or fractional, and anything past roughly 68 years. Use a multiple ofTime.second,Time.minute,Time.hourorTime.day. The raise fails at the offending call rather than at the plugin;skyr runsurfaces it locally, andskyr checkdoes not evaluate, so it does not. A calendar-month span (Time.month,Time.year) is refused earlier still: a TTL is aTime.Duration, a fixed length, and a calendar span is aTime.CalendarDuration— a type error, whichskyr checkdoes report. Records default to the zone'sdefaultTtl, itself 5 minutes unless set. - The AAAA record above tracks the pod's IPv6 because Skyr re-evaluates the config and updates the record when the pod is replaced. Anything outside Skyr should reference the stable IPv4 or the domain, never a pod IPv6.
TLS
Skyr routes TCP to the pod; it does not terminate TLS — certificates live in
your containers. For public HTTPS the practical pattern is a
Caddy sidecar: it obtains and renews ACME
certificates automatically and reverse-proxies the app over localhost:
let caddyfile = Container.ephemeralVolume({
files: #{ "Caddyfile": .literal("example.com\n\nreverse_proxy localhost:8080\n") },
})
let pod = Container.Pod({
name: "web",
internetAddress: ip,
containers: #{
"app": { image: image.url, cpu: 500, memory: 268435456 },
"caddy": {
image: "caddy:2",
cpu: 250,
memory: 134217728,
mounts: #{ "/etc/caddy": { volume: caddyfile, readOnly: true } },
},
},
})
pod.Port({ port: 80, public: true }) // ACME HTTP-01 challenge + redirect
pod.Port({ port: 443, public: true })
(The domain must already resolve to the pod for ACME to succeed.) The zone's
CAARecord can restrict which CAs may issue for the domain.
Alternatively, obtain the certificate itself declaratively with
Skyr/PKI/ACME and mount the PEM into whatever terminates TLS — no in-container
ACME client. DNS-01 composes with Skyr/DNS and is the only method that issues
wildcards:
import Skyr/PKI
import Skyr/PKI/ACME
let key = PKI.ECDSAPrivateKey({ name: "web-tls" })
let account = ACME.Account({
name: "prod",
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
contacts: ["mailto:ops@example.com"],
agreeToTermsOfService: true,
})
let cert = account.DNS01Certificate({
privateKey: key.pem, // the sealed key's Secret Version QID, never a PEM
domains: ["example.com", "*.example.com"],
zone: zone, // the DNS.Zone above — it satisfies the ChallengeZone façade
})
// cert.certificate is pending until issued (status `.issued`), so anything
// mounting cert.certificate.pem is ordered after issuance. Skyr publishes the
// challenge records, drives validation, and renews automatically with no gap.
directoryUrl must be an https CA on the public internet: a deployed
account refuses a loopback, private, link-local, or otherwise non-public
directory (an internal CA is reachable only under skyr run, which hosts the
plugin on your own machine).
The same restriction applies to the hosts the challenge self-check reaches, so
an HTTP-01 domain and a DNS-01 zone's nameservers have to resolve publicly —
but there it shows up as a certificate that waits in its challenge state
rather than one that fails, since an unreachable host is indistinguishable
from a challenge that is not in place yet.
Certificates/chains are public PEM outputs; the private key stays sealed in
the secrets vault — deliver it by seeding an ephemeral volume with
.secret(key.pem) and mounting that volume into whatever terminates TLS:
let tls = Container.ephemeralVolume({
files: #{
"tls.crt": .literal("{cert.certificate.pem}{cert.certificate.chainPem}"),
"tls.key": .secret(key.pem),
},
})
// …inside the terminating container: reads /run/tls/tls.{crt,key}
mounts: #{ "/run/tls": { volume: tls, readOnly: true, userId: 1000 } }
A volume carrying any .secret is mounted owner-only (0700 root dir, 0600
files — an explicit permissions on the mount overrides the mode) owned by
the mount's userId/groupId, defaulting to root — so name the uid the
image's USER runs as, or the process cannot read its own key.
Grants are explicit: the deployment role needs
secret:View/Write/Delete on the key's and the ACME account's
resource-scoped secrets (an IAM.Policy with wildcard objects like
"<org>/<repo>::*" covers them — same stanza as the Secrets bullet below).
For private or internal chains, Skyr/PKI generates keys and signs CSRs
in-config.
Private networking
Pods reach each other privately over a Network — a virtual layer-2 network
with an RFC1918 CIDR (/16–/30):
let net = Container.Network({ name: "app", cidr: "10.42.0.0/24" })
let api = Container.Pod({
name: "api",
containers: #{ "api": { image: image.url, cpu: 500, memory: 268435456 } },
networks: #{ "app0": net },
})
// Internal DNS: resolvable as api.app.internal by attached pods.
net.dns.ARecord({
name: "api",
addresses: [api.networkAddresses["app0"] ?? ""],
})
networks is keyed by interface name (not eth0/lo). Each pod gets an
inner IPv4 per attachment in networkAddresses, keyed the same way; every
interface you attached is present, reading as pending until its address is
allocated, so the record above defers instead of publishing an empty one.
Attached pods can also open non-public ports to accept internal-only traffic.
Internal DNS records require the network to have a name and resolve as
<record>.<netname>.internal ("@" for the network apex). A pod may not
attach two networks that share a name — the lookup would be ambiguous, so
it's rejected at eval. Traffic on a Network never leaves the private plane and
is not metered.
An internal DNS name belongs to whoever publishes it first: a name another
environment already holds on the same network is not overwritten — the deploy
fails, naming the holder — and only the holder can change or remove its own
record. Removing it always works, even after the holder's grant is
withdrawn; changing it goes through the grant. Attaching needs
resource:AttachPodToNetwork on the network; publishing needs the separate
resource:AttachDnsRecordToNetwork.
Routing between networks
A Network on its own is a closed island. Container.Router connects several
of them and routes between them, with an optional stateful ACL. There is no
peering resource — a router is a machine on each LAN:
let corp = Container.Network({ name: "corp", cidr: "10.42.0.0/24" })
let dmz = Container.Network({ name: "dmz", cidr: "10.43.0.0/24" })
Container.Router({
name: "edge",
networks: #{ "corp": corp, "dmz": dmz },
defaultAction: .deny,
rules: [
{ source: corp.cidr, destination: dmz.cidr, action: .allow },
],
})
- Real addresses, no NAT. Pods reach peer-network pods at their own inner
IPv4s and keep exactly the interfaces they declared. A deployed router draws
an ordinary address on each member network — reported in
addresses, keyed by the member labels, and answeringpingand nothing else. A router'snetworkskeys are labels, not interface names: no interface, no DNS meaning. - Member CIDRs must be disjoint. Overlapping members are an eval error; a member whose address space is already routed in the closure it joins fails the deploy, naming the CIDR and your own member label (never the foreign network). Two deploys racing into an overlap resolve deterministically — the earlier-established membership wins and the loser's space is routed nowhere.
- Any member count is valid, zero and one included (a useful intermediate
state while a handle or grant is pending). Members may live in different
regions; a router takes no
regioninput. - ACL:
rulesmatch top to bottom, first match wins, falling through todefaultAction(.allowby default; omit both and everything routes). Stateful — replies on established connections always pass, so rules govern who may initiate.source/destinationare CIDR strings matched on real addresses ("0.0.0.0/0"= anything), and transit passing through is filtered by the same rules. The router is the only filter — there's no second, per-pod rule set to keep in step. It governs access, not exposure: denied traffic still crosses the private network before it's dropped at the destination's boundary, so an ACL is not a shield against volume. - Chaining is transitive. Two routers sharing a network route through each other, so joining a router gets you everything it routes, including onward routers — reachability exists only where a chain creates it. Source addresses can't be forged across a router; there's nothing to configure.
- Updates are in place. Changing
networks,rulesordefaultActionreconverges the router on every node within seconds — surviving members keep their addresses and no pod is recreated; only a newnameis a new router. The router is rebuilt as it reconverges, so expect a brief window where it forwards nothing. That makes the ACL the router owner's prompt kill switch. - DNS follows routability, not the ACL. A pod also resolves
<record>.<netname>.internalon named networks it can reach — even through a deny-all router (a name isn't traffic). Routed names are qualified only (search domains stay the attached netnames), direct attachment shadows routing, and two routed networks sharing a netname resolve to NXDOMAIN. Give the networks of a shared fabric distinct names. - Membership needs
resource:AttachRouterToNetworkon each member network, checked on every transition of the router.
Cross-org: the DMZ pattern. Never make your network a member of the other
org's router. Org A declares a small transit network T and grants org B's
deployment role AttachRouterToNetwork on T — the only cross-org grant. Each
side then connects with a router it owns (A: its network + T; B: its network
- T), and chaining carries traffic between them. Each side's ACL sits on its own router, so each keeps a kill switch that converges in seconds. Revoking the grant is lazy and freezes rather than evicts (the router's next transition fails; established routes stay), and a network owner who doesn't own the adjacent router has no lever short of deleting the network — which is exactly why each side brings its own router.
Under skyr run routers are emulated: same reachability, same ACL, same DNS
closure, and member networks are isolated from each other absent a router. Four
local-only divergences, none of which a deployment produces: every local pod
also shares the skyr-run-default network (so isolation is faithful for
member-network addresses, not for the shared ones behind them); DNS records are
injected at pod creation while routes converge live, so a running local pod can
hold a route to a network whose names it can't yet resolve; a local router's
address on a member network is that network's gateway (the reserved first host)
rather than a pool address, which is what addresses reports locally; and those
addresses answer more than ping, since they are the bridge gateways local name
resolution and internet egress already go through.
Sharing networks, volumes, and addresses across repos
Networks, persistent volumes, and internet addresses are shareable across
repositories: the owner exports the handle its constructor returned, the
consumer imports it via a Package.scle dependency (the scl skill covers
imports). The identity strings on those handles are opaque and carry their
owner — guessing one is not a route in, and a hand-assembled handle is refused
where a pod attaches, mounts, or binds it.
Using a shared resource needs the read chain (repository:View +
environment:View + resource:View) plus the use verb, whose object is
the used resource's full QID. One policy in the owner's config covers it:
IAM.Policy({
name: "app-uses-platform",
subjects: ["acme/app::*:Skyr/IAM.Role:app-deployer"],
verbs: ["repository:View", "environment:View", "resource:View",
"resource:AttachPodToNetwork", "resource:MountVolume"],
objects: ["acme/platform", "acme/platform::main",
"acme/platform::*:Skyr/Container.Network:*",
"acme/platform::*:Skyr/Container.PersistentVolume:uploads"],
})
The five use verbs are resource:AttachPodToNetwork,
resource:AttachRouterToNetwork, resource:AttachDnsRecordToNetwork,
resource:MountVolume, and resource:BindInternetAddress. (Two more verbs sit
outside the standard family without being use verbs at all:
resource:ForwardPodPort and resource:ExecInPod are checked when an
operator reaches into a running pod, not at any transition — see "Watching a
rollout".) Enforcement is
uniform — your own environment's resources are checked too; it's invisible
only because a repo's deployment role defaults to the org's Super role, which
short-circuits within that org. A restricted role needs the grants for its own
resources as well. A refusal names the verb and the object QID, plus the acting
role when the deployment presented one.
Revocation is lazy: it bites on the holder's next transition, never
detaching a running pod. There is no owner-initiated eviction, so a resource
you share can be held by its current user until they release it — for a shared
InternetAddress the owner's only forced remedy is delete + recreate, which
changes the public IP; for a network someone else's router routes, the prompt
lever belongs to whoever owns that router (its ACL), which is why cross-org
connections are shaped so each side owns the router in front of its own
network. Dropping an attachment/membership/mount/binding always succeeds, even
after the grant is gone.
Restart, jobs, and scheduled runs
There is no Job or CronJob resource — run-to-completion and scheduling are
per-container policy on an ordinary Container.Pod. Each container takes five
optional policy fields:
restart—.always(default: restart on any exit — crash recovery for services and sidecars),.onFailure(restart only on a non-zero exit; a zero exit is final — retry-until-success), or.never(any exit is final). Restarts run in place with automatic exponential backoff. The default means every pod gets crash recovery for free.keepAlive—Bool, defaulttrue. The pod is reaped (node resources freed) once every keep-alive container has terminated. At least one container must keep the pod alive (else rejected at eval). AkeepAlive: falsesidecar is stopped when the pod reaps.maxRetries—Int?, valid only with.onFailure; absent means retry forever,NpermitsN + 1executions before the last failing exit is final.timeout—Time.Duration?, a per-attempt kill budget. ATime.Durationis a fixed length (a multiple ofTime.second/Time.day/…); a calendar-month span is aTime.CalendarDurationand does not type-check here.probe—Probe?, absent by default: how Skyr asks a container whether it is still serving, for a workload that can stop serving without exiting.{ kind: .http({ port, path? }) }(2xx/3xx passes) or{ kind: .tcp({ port }) }(a completed connection passes), plus optionalinitialDelay/interval/timeout/startupWindow(each aTime.Duration, so a fixed length, and positive) andfailureThreshold(a positive count), each defaulted by Skyr when omitted. The check runs from inside the pod, so the port need not be onepod.Portopened — and usually should not be. One probe covers startup, readiness and liveness, because its role changes with the container's phase: before its first success it is readiness (a failure kills nothing, so a slow start is safe; a container still failing afterstartupWindowis restarted as hung), after it is liveness (failureThresholdfailures in a row stop the container). Its first success is also what the pod's readiness waits for, which is what aReplicaSetreplica built from the pod stands on. A probe never restarts anything by a route of its own — it stops the container andrestartdecides, so a workload that hangs sick gets the same backoff, retry cap and crash-loop judgment as one that exits sick. Adding, changing or removing a probe recreates the pod.skyr runaccepts one and does not execute it.
A job is just a pod whose container uses .onFailure/.never; job-ness is
implied by configuration, never declared.
import Skyr/Container
import Std/Time
// A one-shot migration: retries until it exits 0, then the pod reaps.
let migrate = Container.Pod({
name: "migrate",
containers: #{ "migrate": {
image: migrateImage.url,
cpu: 500,
memory: 268435456,
restart: .onFailure,
maxRetries: 5,
timeout: Time.multiply(Time.minute, 10),
} },
})
Completion surfaces as the pod's exitCodes: #{Str: Int} output — the
final exit code of each container that can reach one, keyed by name. Only
.onFailure and .never containers are keyed; a .always one restarts
forever and has no key, so reading it yields nil. A key that exists is a
pending value until that container's final termination, so you sequence and
gate with plain control flow, keying the specific container:
// `serve` is created only once the migration exits cleanly; while the code is
// pending the `if` condition is pending, neither branch runs, and the pass says
// so — it is a branch that could have declared and did not, which holds the
// rollout exactly as a resource with a pending input does.
let serve = if (migrate.exitCodes["migrate"] == 0)
Container.Pod({ name: "serve", containers: #{ "web": { image: webImage.url, cpu: 1000, memory: 536870912 } } })
The gate lives in the if condition, not the pod's inputs, so serve's
identity stays stable — and gating on migrate is an edge all the same, so
teardown destroys serve first and migrate outlives it. An else branch
handles remediation on a final non-zero exit.
Two consequences of unkeyed .always containers, both quiet: a comparison
against a missing key is nil == 0 — false immediately, taking the else
branch rather than waiting, so a gate naming the wrong container looks like a
gate whose condition is merely false (compare against nil when you mean "has
it terminated"); and a fold over the whole map no longer wedges on pending,
but over a pod of nothing but .always containers it folds an empty map,
where List.all is vacuously true. Key the container you mean.
Cron is a job pod whose name embeds a tick value from Time.now (or
Time.tick(interval, offset) for an offset schedule like "04:00 UTC daily").
Each new tick is a new resource identity, so the new-named pod is created and
the previous destroyed:
let tick = Time.now(Time.hour)
Container.Pod({
name: "hourly-{tick.epochMillis}",
containers: #{ "report": { image: reportImage.url, cpu: 500, memory: 268435456, restart: .onFailure } },
})
Accepted limits: concurrency is Replace only (a tick boundary kills a still-
running previous pod — keep timeout under the interval), missed ticks are
skipped (no catch-up), a pod's recorded history evaporates one window
later, and boundaries are UTC only. Completion is at-least-once, so
jobs must be idempotent — a node death before completion is recorded re-runs
the job. Full reference: curl -s https://skyr.foo/~docs/jobs.md.
Other capabilities
- Volumes.
Container.PersistentVolume({ name, size })is region-scoped storage that outlives pods (min 8 MiB; a pod can only mount volumes from its own region).Container.ephemeralVolume({ files, size, name })is scratch space living and dying with the pod, optionally seeded withfiles: #{ "<path relative to the mount root>": .literal("…") }— values are the same two arms asenv(.literal(…)/.secret(qid)), and a bare string is a type error (see the Caddy example). Mounting a seeded volume is how files reach a container — there is no pod-level file input. Mount via a container'smountsdict keyed by absolute path:#{ "/data": { volume: v } }, with optionalreadOnly/subPath/permissions/userId/groupId; two containers mounting the same ephemeral volume must spell the same permissions and owner. Read-only mounts of seeded ephemeral content update in place when the content changes, unless the new content outgrows the disk the pod claimed (each non-empty file rounds up to a whole 4 KiB page, so adding one to a volum
Truncated - read the full file at https://github.com/skyr-cloud/agent-plugin/blob/ff3501267a45aea02eca9b1d2e478e549cd3a48b/skills/deploy/SKILL.md.