factory.yml — the manifest

factory.yml says what to run. It lives in the caller repo, at the root, and it is the only file named factory.yml anywhere in the system (plan D9). The where — accounts, tokens by name, state — is the host spec it points at through host.ref.

Everything a developer container needs is declared here and generated from here: the compose environment: block, the Traefik routes, the env file, the tmux windows. Nothing is read out of the container image at provision time, so the image can be generic (factory-base) and the repo-specific part is this file.

  • Schema: schema/factory.schema.json (generated from packages/engine/src/schema/factory.ts; put # yaml-language-server: $schema=… at the top for editor completion). The JSON Schema is deliberately looser than factory validate: refinements such as “exactly one default port” or “{port:NAME} names a public port” cannot be expressed in JSON Schema, so always run the CLI.
  • Full example: examples/factory.clankerengineer.yml.
  • Minimal example: examples/factory.minimal-node.yml.
  • Validate: node packages/engine/bin/factory.js validate <path> from a CLANKERNET checkout (the reusable workflow’s preflight runs the same verb, plan T050). NOT npx factory — the package is private and unpublished, and factory on the public registry is an unrelated module. Exit 0 = valid; warnings go to stderr and do not fail the run.

Placeholders

One grammar for every templated string (hostnames, env.templated, coordination.actorLabel, access.tailscale.hostname). Unknown placeholders are validation errors, not literal text in an env var.

PlaceholderRenders toWhere
{user}the developer’s GitHub login (the identity that authorised the provision)anywhere
{repo}repo.nameanywhere
{zone}the DNS zone the hostnames live underanywhere; hostname templates must END in .{zone}
{hostname}the rendered hostnames.defaultenv.templated only
{port}the port’s namehostnames.port only
{port:NAME}the rendered hostname of the public port called NAMEenv.templated
{host:accountId}the host’s Cloudflare account id (ingress.accountId, non-secret)env.templated

Keys

version (required)

1. The manifest format version.

repo (required)

KeyTypeDefaultMeaning
nameslugShort repo name; {repo} in templates, half of the compose project name
urlurlthe calling repositoryClone URL
defaultBranchstringmainChecked out on first clone; sync.branch defaults to it
pathabs pathClone location inside the container
userunix userdeveloperThe user hooks and services run as

host (required)

host.ref — where the host spec is. Three forms:

FormResolved from
./hosts/<name>.ymla file in the caller repo, relative to the manifest (not the cwd)
repo:<owner>/<repo>@<ref>:<path>.ymlthe host owner’s repo through the GitHub contents API; FACTORY_GITHUB_TOKEN if private (the workflow’s checkout_token_from)
stack:<org>/<project>/<stack>the host stack’s non-secret hostSpec output (pulumi stack output hostSpec --json; never --show-secrets)

The workflow’s host input (plan T050) overrides the manifest’s ref, so a repo can be provisioned onto a different host without editing the file. It — and --host on every CLI verb, validate included — also takes a host id (hosts/<id>.yml in the CLANKERNET checkout, FACTORY_ROOT) or a path to a .yml in the caller’s checkout, beside the three ref forms above.

image (required)

KeyTypeDefaultMeaning
baseimage refThe factory base image, ghcr.io/clankerlabs/factory-base:1. The entrypoint is inherited from it
refimage refrun base directlyThe repo’s own image built FROM base (repo layer only, no entrypoint of its own)
pull.tokenFromsecret:NAMERegistry pull token when ref is private. The host spec can carry one too (image.pull)

access

KeyTypeDefaultMeaning
github.orgloginOnly members of this org may provision. Needs a PAT with read:org in the host secrets (GITHUB_TOKEN cannot read membership)
github.userslogin[]An explicit allow-list; needs no PAT
ssh.keySourcegithub-keys | sshid | listgithub-keysWhere authorized keys come from (plan D20). github-keys = https://github.com/<login>.keys
ssh.users.<login>.sshidstringthe loginsshid.io username override
ssh.users.<login>.keysstring[]Static keys, for list
tailscale.hostnametemplate (single label)first label of the default hostnameTailscale device name; matched exactly on purge (plan T031)

Refused: access.github with neither org nor users — an empty block would authorise everyone the repo’s workflow_dispatch already does, while looking like a restriction.

hostnames (required)

KeyTypeDefaultMeaning
zonezonethe host’s first dns.zones[]Zone the hostnames render under; must be in the host’s dns.zones[]
defaulttemplateThe default port’s hostname, e.g. dev-{user}.{zone} or {user}-{repo}.{zone}. Must contain {user}
porttemplateEvery other public port, e.g. {port}-dev-{user}.{zone}. Must contain {port} when more than one port is public
advancedCertificatebooleanfalseOpt in to hostnames deeper than one label under the zone

Templates must end in .{zone}. Cloudflare’s free Universal SSL covers exactly one label under the zone (*.example.com), so {user}.{repo}.{zone} is refused unless advancedCertificate: true — an Advanced Certificate is a paid, per-zone order, and the failure without one is a 526 on first visit.

The compose project name is derived from default: dev-{user} when the template is exactly dev-{user}.{zone} (the legacy clankerengineer shape, so provision reconciles a running container instead of recreating it), else {user}-{repo}.

ports (required, ≥1)

KeyTypeDefaultMeaning
port1–65535Container port
nameslug{port:NAME}, the {port} label, the Traefik router name
publicbooleanfalseRouted through ingress. Private ports get no hostname and no route
defaultbooleanfalseServed at hostnames.default. Exactly one port must be default
pathRoutedbooleanfalseServed on hostnames.default by path (Metro’s /_expo, /assets, *.bundle) instead of its own hostname
pathPrefixespath[]PathPrefix rules; path-routed ports need this and/or pathRegexps
pathRegexpsstring[]PathRegexp rules (Go regexp)
healthpathProbed after provision at https://{hostnames.default}{health}. Default port only. A missing listener FAILS the run

Refused: two defaults, a path-routed default, duplicate names or numbers, health on a non-default port, pathPrefixes on a non-path-routed port.

services

KeyTypeDefaultMeaning
postgres.versionimage-provided | integerimage-providedThe major the base image ships (15). Any other number is refused
postgres.databasesname[][app]Databases created in the container-local cluster
postgres.exportsENV_NAME: database{}Each written as a postgresql:// URL to the local cluster
procfilepathProcfile.devEntries become tmux windows (factory-start)
primaryslugThe Procfile entry the default port belongs to
tmuxSessionslugdev

Omit postgres for no database. There is deliberately no shared cloud database seam: one shared project meant one container’s schema push was visible in all of them.

resources

Host-stack resources the container gets credentials for. Both kinds are { protect: true } on the host stack; nothing in the default program deletes them, and factory resources destroy --host H --name N is the only path that empties and deletes one (plan D4).

KeyTypeMeaning
r2[].nameslugBucket name
r2[].exports.{endpoint,accessKeyId,secretAccessKey,bucket}env nameWhich env names carry each value
upstash[].nameslugRedis database name
upstash[].regionstring
upstash[].exports.{restUrl,restToken}env name

env

The container’s env file is the union of five disjoint sets, and the validator refuses a name declared twice or a name the engine reserves.

KeyTypeDefaultMeaning
filepath relative to repo.path.env.localWritten 0600, sourced by every process
staticNAME: value{}Literal, committed values
templatedNAME: template{}Rendered per container (placeholders above); {port:NAME} must name a public port
secretsNAME[][]Fetched from the host’s secrets provider. Exactly these reach the container — host-provisioning tokens never do
computed[{script, exports[]}][]script runs on the runner before provision and prints NAME=value lines for the listed names
git.tokenFromsecret:NAMEClone/push credential (a PAT in v1, plan D11)
git.userName, git.userEmailstringContainer git identity

Reserved (set by the engine, refused here): FACTORY_USER, FACTORY_REPO, FACTORY_HOSTNAME, FACTORY_ZONE, GITHUB_USERNAME, SSHID_USERNAME, TAILSCALE_AUTH_KEY, GIT_TOKEN, GIT_USER_NAME, GIT_USER_EMAIL, REPO_URL, GITFWD_HOME, GITFWD_REPO.

hooks

Each list holds commands (a plain string, fatal) or { run, name?, fatal? }. Hooks run in repo.path as repo.user with the env file sourced. fatal defaults to true: a silently half-provisioned container is exactly the bug the legacy entrypoint’s || true produced.

KeyWhen
postCloneAfter the clone, before the env file exists
provisionAfter the env file is written, in order — first run and every re-provision
syncAfter factory-sync fast-forwards sync.branch
envNot a hook list: an allow-list of RESERVED names (GIT_TOKEN, …) a hook may see. Default []. Hooks run under sudo, which resets the environment, so a reserved value is invisible to them unless named here; env.* names need no listing (they are in the env file). A non-reserved name is refused

agents

KeyTypeDefaultMeaning
claude.mcpslug[][]MCP servers to register for Claude Code
pluginsstring[][]
codexbooleanfalse

features

KeyTypeDefaultMeaning
innerDockerbooleanfalseRun under sysbox-runc when the host has it; a WARNING otherwise, never a failure
moshbooleanfalseInstall mosh; the host firewall’s mosh flag opens its ports

coordination

Present = the container joins a GFP/1 coordination space; absent = no GitFWD at all (env | grep -c GITFWD is 0).

KeyTypeDefaultMeaning
repoowner/nameThe GFP/1 log repository
spaceslugcore
actorLabeltemplate{user}Label of the container-minted actor (plan D13)
cloneDirabs path/home/developer/coordination

sync

KeyTypeDefaultMeaning
branchstringrepo.defaultBranchBranch factory-sync fast-forwards
autoSyncSecondsinteger0 (off)factory-auto-sync poll interval; below 30 is refused
restartslug[][]Procfile windows restarted after a sync

teardown

KeyTypeDefaultMeaning
preserveVolumesbooleantrueteardown keeps the volumes (re-provision over them, same hostname); only purge removes them (plan D5)

Refusals and warnings, in one place

ConditionResult
hostname template more than one label under {zone} without advancedCertificate: trueerror
services.postgres.version other than image-provided / 15error
access.github with neither org nor userserror
hostnames.zone not in the host’s dns.zones[]error
unknown placeholder, or {port:NAME} naming a private/unknown porterror
a name in two env sets, or a reserved nameerror
hooks.env naming a non-reserved name, or a name twiceerror
≠1 default port; path-routed default; duplicate port name/numbererror
an unknown key anywhere (all objects are closed)error
a literal where secret:NAME is requirederror
public: true ports on a host with ingress.provider: nonewarning
features.mosh on a host with firewall.mosh: falsewarning
image.ref with no pull token on either sidewarning