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.
| Placeholder | Renders to | Where |
|---|
{user} | the developer’s GitHub login (the identity that authorised the provision) | anywhere |
{repo} | repo.name | anywhere |
{zone} | the DNS zone the hostnames live under | anywhere; hostname templates must END in .{zone} |
{hostname} | the rendered hostnames.default | env.templated only |
{port} | the port’s name | hostnames.port only |
{port:NAME} | the rendered hostname of the public port called NAME | env.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)
| Key | Type | Default | Meaning |
|---|
name | slug | — | Short repo name; {repo} in templates, half of the compose project name |
url | url | the calling repository | Clone URL |
defaultBranch | string | main | Checked out on first clone; sync.branch defaults to it |
path | abs path | — | Clone location inside the container |
user | unix user | developer | The user hooks and services run as |
host (required)
host.ref — where the host spec is. Three forms:
| Form | Resolved from |
|---|
./hosts/<name>.yml | a file in the caller repo, relative to the manifest (not the cwd) |
repo:<owner>/<repo>@<ref>:<path>.yml | the 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)
| Key | Type | Default | Meaning |
|---|
base | image ref | — | The factory base image, ghcr.io/clankerlabs/factory-base:1. The entrypoint is inherited from it |
ref | image ref | run base directly | The repo’s own image built FROM base (repo layer only, no entrypoint of its own) |
pull.tokenFrom | secret:NAME | — | Registry pull token when ref is private. The host spec can carry one too (image.pull) |
access
| Key | Type | Default | Meaning |
|---|
github.org | login | — | Only members of this org may provision. Needs a PAT with read:org in the host secrets (GITHUB_TOKEN cannot read membership) |
github.users | login[] | — | An explicit allow-list; needs no PAT |
ssh.keySource | github-keys | sshid | list | github-keys | Where authorized keys come from (plan D20). github-keys = https://github.com/<login>.keys |
ssh.users.<login>.sshid | string | the login | sshid.io username override |
ssh.users.<login>.keys | string[] | — | Static keys, for list |
tailscale.hostname | template (single label) | first label of the default hostname | Tailscale 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)
| Key | Type | Default | Meaning |
|---|
zone | zone | the host’s first dns.zones[] | Zone the hostnames render under; must be in the host’s dns.zones[] |
default | template | — | The default port’s hostname, e.g. dev-{user}.{zone} or {user}-{repo}.{zone}. Must contain {user} |
port | template | — | Every other public port, e.g. {port}-dev-{user}.{zone}. Must contain {port} when more than one port is public |
advancedCertificate | boolean | false | Opt 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)
| Key | Type | Default | Meaning |
|---|
port | 1–65535 | — | Container port |
name | slug | — | {port:NAME}, the {port} label, the Traefik router name |
public | boolean | false | Routed through ingress. Private ports get no hostname and no route |
default | boolean | false | Served at hostnames.default. Exactly one port must be default |
pathRouted | boolean | false | Served on hostnames.default by path (Metro’s /_expo, /assets, *.bundle) instead of its own hostname |
pathPrefixes | path[] | — | PathPrefix rules; path-routed ports need this and/or pathRegexps |
pathRegexps | string[] | — | PathRegexp rules (Go regexp) |
health | path | — | Probed 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
| Key | Type | Default | Meaning |
|---|
postgres.version | image-provided | integer | image-provided | The major the base image ships (15). Any other number is refused |
postgres.databases | name[] | [app] | Databases created in the container-local cluster |
postgres.exports | ENV_NAME: database | {} | Each written as a postgresql:// URL to the local cluster |
procfile | path | Procfile.dev | Entries become tmux windows (factory-start) |
primary | slug | — | The Procfile entry the default port belongs to |
tmuxSession | slug | dev | |
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).
| Key | Type | Meaning |
|---|
r2[].name | slug | Bucket name |
r2[].exports.{endpoint,accessKeyId,secretAccessKey,bucket} | env name | Which env names carry each value |
upstash[].name | slug | Redis database name |
upstash[].region | string | |
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.
| Key | Type | Default | Meaning |
|---|
file | path relative to repo.path | .env.local | Written 0600, sourced by every process |
static | NAME: value | {} | Literal, committed values |
templated | NAME: template | {} | Rendered per container (placeholders above); {port:NAME} must name a public port |
secrets | NAME[] | [] | 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.tokenFrom | secret:NAME | — | Clone/push credential (a PAT in v1, plan D11) |
git.userName, git.userEmail | string | — | Container 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.
| Key | When |
|---|
postClone | After the clone, before the env file exists |
provision | After the env file is written, in order — first run and every re-provision |
sync | After factory-sync fast-forwards sync.branch |
env | Not 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
| Key | Type | Default | Meaning |
|---|
claude.mcp | slug[] | [] | MCP servers to register for Claude Code |
plugins | string[] | [] | |
codex | boolean | false | |
features
| Key | Type | Default | Meaning |
|---|
innerDocker | boolean | false | Run under sysbox-runc when the host has it; a WARNING otherwise, never a failure |
mosh | boolean | false | Install 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).
| Key | Type | Default | Meaning |
|---|
repo | owner/name | — | The GFP/1 log repository |
space | slug | core | |
actorLabel | template | {user} | Label of the container-minted actor (plan D13) |
cloneDir | abs path | /home/developer/coordination | |
sync
| Key | Type | Default | Meaning |
|---|
branch | string | repo.defaultBranch | Branch factory-sync fast-forwards |
autoSyncSeconds | integer | 0 (off) | factory-auto-sync poll interval; below 30 is refused |
restart | slug[] | [] | Procfile windows restarted after a sync |
teardown
| Key | Type | Default | Meaning |
|---|
preserveVolumes | boolean | true | teardown keeps the volumes (re-provision over them, same hostname); only purge removes them (plan D5) |
Refusals and warnings, in one place
| Condition | Result |
|---|
hostname template more than one label under {zone} without advancedCertificate: true | error |
services.postgres.version other than image-provided / 15 | error |
access.github with neither org nor users | error |
hostnames.zone not in the host’s dns.zones[] | error |
unknown placeholder, or {port:NAME} naming a private/unknown port | error |
a name in two env sets, or a reserved name | error |
hooks.env naming a non-reserved name, or a name twice | error |
| ≠1 default port; path-routed default; duplicate port name/number | error |
| an unknown key anywhere (all objects are closed) | error |
a literal where secret:NAME is required | error |
public: true ports on a host with ingress.provider: none | warning |
features.mosh on a host with firewall.mosh: false | warning |
image.ref with no pull token on either side | warning |