No description
  • Swift 99.3%
  • Shell 0.7%
Find a file
Jimmy Hough Jr. 575832b5fe
serve installs itself as the platform's kept-alive service (#100)
A dashboard someone has to remember to start is not a control plane, and the status
board's collector now polls serve on a schedule. hatchery serve --install writes the
agent for this platform's init and loads it: a launchd user agent on macOS, kept alive
and run at load, logging to ~/Library/Logs/hatchery-serve.log, or a systemd user unit
on Linux with Restart=always and the loginctl enable-linger note for boots without a
login. --uninstall takes it away. Every argument is spelled into the agent, because
neither init starts it with the shell's environment, and the manifest is resolved at
install time for the same reason.

An off-host bind still needs --token, and the install says the token lands in the
plist. On macOS the install also warns that Local Network Privacy may ask on first
launch, because the estate has watched launchd agents lose LAN access before.

Proven on this machine: install on a scratch port, launchd reports running, the page
answers 200, uninstall leaves nothing loaded.

Co-authored-by: t <t@t>
2026-08-23 17:02:32 -05:00
.github/workflows The release verify waits for GitHub to publish (#84) 2026-08-20 13:57:14 -05:00
assets Stop the poll collapsing panels, and say how to get a box in the first place (#11) 2026-08-05 10:53:26 -05:00
docs A Cloud Run clone reaches Cloud SQL through the socket (#97) 2026-08-20 16:24:45 -05:00
Scripts Say when applying would ship new code, before the apply (#76) 2026-08-09 09:34:04 -05:00
Sources serve installs itself as the platform's kept-alive service (#100) 2026-08-23 17:02:32 -05:00
Tests serve installs itself as the platform's kept-alive service (#100) 2026-08-23 17:02:32 -05:00
.gitignore Scaffold HatcheryKit with env contracts and stack manifest 2026-08-01 09:14:02 -05:00
.swift-version Run PRs once not twice, keep build state warm, pin the toolchain, and ship the arm64 binary (#29) 2026-08-07 10:50:36 -05:00
hatchery.example.json Add deploy, which moves an image through the declaration that owns it (#4) 2026-08-04 13:43:05 -05:00
Package.resolved Add a dashboard, served from outside anything it manages (#6) 2026-08-05 09:15:37 -05:00
Package.swift The dashboard scans a box and adopts a find (#92) 2026-08-20 15:23:36 -05:00
README.md Clone a stack from the dashboard, through the same plan the CLI shows (#41) 2026-08-08 08:34:52 -05:00

hatchery

Configure, deploy and monitor MWServer stacks — self-hosted or cloud.

MWServer is the first target, but nothing in the model is MWServer-specific: service kinds and their environment contracts are data, so other services drop in without changing the core.

Status

Early, but the loop closes: configure, deploy, monitor, all against the live lab.

Working today:

hatchery config validate <config.json> --service mwserver --backend dokku
hatchery stack list --manifest hatchery.json
hatchery stack clone <source> <target> [--create --tofu-dir <dir> --yes]
hatchery status --manifest hatchery.json
hatchery events [--stack <name>]
hatchery config audit --manifest hatchery.json
hatchery config sync <stack> --manifest hatchery.json
hatchery service new <stack> <name> --kind <kind> --domain <d> --image <ref>
hatchery deploy <stack> <service> --image <ref>
hatchery up|down|restart <stack>
hatchery serve

Cloning a stack

stack clone reads a stack's config — live from the box when the backend can answer, the declared sidecar otherwise, and it says which — and decides, key by key, what a copy into another environment should do: carry it, rewrite the names in it, mint a fresh one, or refuse and say why. Nothing that points at the source's database or grants the source's authority is ever copied. Optional keys the source sets ride along; unset ones aren't mentioned. Without --create it only reports; with --create --tofu-dir <dir> --yes it bootstraps the stack, scaffolds the services, layers the carried values on, and prints the config set commands for what still needs a person.

The dashboard has the same two steps behind each stack's clone button: the plan renders with every key's fate before anything is written, and creating walks through the same bootstrap, scaffold and layering the CLI runs — then asks for the keys only a person can supply, exactly as service new does. Secret values never reach the browser; the plan shows their names and what will happen to them.

History and alerts

hatchery serve polls on its own clock (--watch-interval, default 30s), so watching does not depend on a browser tab being open. Every observed change — a service worsening, recovering, or degraded for a new reason — is appended to <manifest>.history.jsonl beside the manifest, printed on the serve console, and listed on the dashboard. hatchery events reads the same file from the CLI. --alert-webhook <url> POSTs worsening transitions (only worsening; recovery is in the record but never wakes anyone) as JSON with a ready-made text line, so anything chat-shaped can consume it directly.

Finding the manifest

A manifest lives next to the config files it names, because configFile resolves relative to the manifest's own directory — which is rarely the directory you are standing in. So a command with no --manifest looks in three places, in order:

  1. ./hatchery.json
  2. $HATCHERY_MANIFEST
  3. ~/.config/hatchery/hatchery.json

An explicitly passed --manifest is used exactly as given and never falls back, because second-guessing it would hide a typo. Finding nothing lists everywhere it looked.

From nothing to something

There was no path from an empty directory to a running service without writing HCL yourself — service new could only add to a configuration that already existed, and the lab's own was written by hand. stack new closes that:

hatchery stack new newlab --host dokku@192.168.0.103 --tofu-dir ~/infra-state/newlab
hatchery service new newlab paylab2 --kind payment-gateway \
  --domain paylab2.opi --image payment-gateway:arm64-6be9967
hatchery config set newlab paylab2 DATABASE_HOST=... DATABASE_USER=... DATABASE_PASSWORD=...

It writes versions.tf, providers.tf and variables.tf, runs tofu init, and records the stack in a manifest beside them. An existing configuration is never written over.

The same three steps are a wizard in the browser, which is the point: the dashboard now starts from an empty state offering to create a stack, and walks through service and secrets without you touching a file. hatchery serve no longer needs a manifest to start — requiring one would mean needing a manifest to reach the tool that writes your first manifest.

A key hatchery cannot supply is left out of the config entirely rather than written as "". The dokku provider rejects a zero-length config value outright (string length must be at least 1), so an empty placeholder makes a brand-new stack fail to plan at the worst possible moment.

The dashboard

Providers are the top level. The page opens on which backends exist, whether this machine is configured for each, and how many stacks use them — before anything is defaulted to one:

PROVIDERS
  Dokku (self-hosted)        ● configured here          1 stack    [set up] [+ stack]
  DigitalOcean App Platform  ● 1 of 3 checks failing    0 stacks   [set up] [+ stack]
  AWS App Runner             ● 1 of 4 checks failing    0 stacks   [set up] [+ stack]
  Google Cloud Run           ● 2 of 3 checks failing    0 stacks   [set up] [+ stack]

Readiness is fetched per provider after the rows are drawn, so one slow box does not hold up the rest of the page, and dokku is checked against a host taken from an existing stack rather than reported as "no host given" on a machine that plainly has one. Starting a stack from a provider row carries that choice into the wizard instead of asking again.

A failing provider names the thing to fix rather than counting them — aws cli beats 2 of 4 checks failing — and checks expands the row into every check with its remedy, the same detail hatchery doctor prints:

  AWS App Runner   * aws cli +2 more    0 stacks   [checks] [set up] [+ stack]

    ok    tofu installed    OpenTofu v1.12.5
    FAIL  aws cli           not found on PATH
          -> brew install awscli
    --    aws credentials   the aws cli is not installed
    --    aws region        the aws cli is not installed

The mark is a cracked egg on the same perch roost draws its rooster on — same 64×64 field, same flat geometry, same #C4602A. roost owns machines; hatchery owns the stacks that hatch on them, and the two marks are meant to read as siblings.

hatchery serve puts the same operations behind a browser:

$ hatchery serve
hatchery serving http://127.0.0.1:7878
  bound to loopback — not reachable from the LAN
  from elsewhere: ssh -L 7878:localhost:7878 <this-host>

One self-contained page, no build step, no asset pipeline — the markup is a string in the binary, so the page cannot drift from the code serving it. It polls status every ten seconds and follows the browser's light or dark theme.

It does not run inside what it manages

hatchery manages deployments, so it deliberately is not one. Running it as an app on the box it administers means a restart of that stack kills the tool mid-action, and the moment you most need it — box wedged, apps down — is exactly when it would not be there. It is a local process. There is no Dockerfile here on purpose.

Getting a box ready in the first place

hatchery doctor tells you dokku is not answering; it cannot tell you how to put dokku there. hatchery setup is that half — installing dokku, authorizing your key for the dokku user, creating the shared docker network, provisioning the database role hatchery refuses to invent. The dashboard shows the same list behind No box yet?.

It is a checklist rather than a script on purpose. These steps touch a machine's package manager, its firewall and its SSH configuration, and running that blind from a dashboard is not something hatchery should do on your behalf.

Prerequisites, checked before anything is written

hatchery doctor checks the things that otherwise surface halfway through tofu init — as a provider error that never mentions the missing SSH key that actually caused it:

  ok   tofu installed: OpenTofu v1.12.5
  ok   ssh client: OpenSSH_10.2p1, LibreSSL 3.3.6
  FAIL box reachable: ssh: connect to host 10.99.99.99 port 22: Operation timed out
       -> the box is not reachable from here; check you are on the same network
  --   dokku responds: the box could not be reached

One cause produces one failure: an unreachable box skips the dokku check rather than reporting a second thing to fix. The wizard runs the same checks as its middle step, before creating anything.

The SSH target is the field worth explaining, and the wizard now does: it is how hatchery reaches the box to create and manage apps, the user must be dokku — that account is what turns an SSH command into a dokku command — and your key must already be authorized for it.

Stacks carry their provider

Every stack header shows its provider's glyph, and once more than one provider is in use the list groups under them:

▣ Dokku (self-hosted)  2
    ▣ mwlab                    dokku · dev        degraded
    ▣ mwlab-2 ▲                dokku · staging    responding

☁ Google Cloud Run  1
    ☁ mwprod                   cloudRun · prod    ready

Grouping only when it earns its place: with a single provider a heading over every stack is a level of nesting that says nothing, and the glyph on each header already carries it.

Incomplete configuration

A service whose declared config is missing required keys gets a ▲ next to its name, and the stack gets one too:

mwlab-2                                    staging  ▲
  mwserver-staging ▲   responding   62ms
                       ▲ 9 required keys missing

Deliberately separate from health. That service answers HTTP and reads responding, while its declaration could not boot if applied — a service can run happily on values its config file no longer contains. Health is about what is running; the triangle is about what is declared.

Computed from the declared file rather than the box, so it costs a local read on a ten-second poll instead of an SSH round trip per service. config audit is the one that asks the box.

Saved SSH targets

An address is the same thing in six places, and retyping it is how 192.168.0.103 becomes 192.168.0.130 in exactly one of them.

hatchery host add opi dokku@192.168.0.103
hatchery stack new lab --backend dokku --host @opi --tofu-dir ~/infra-state/lab
hatchery host list

The list is saved names, plus targets already in use by a stack, plus anything roost advertises. roost configures boxes and hatchery configures the stacks on them, so roost is the one that knows a machine exists — ~/.roostrc already declares ROOST_DOKKU_HOST, and hatchery reads it rather than making you type an address written down two directories away. Nothing had to change in roost for that to work. The file is read, never sourced: sourcing an rc file to learn one variable would run whatever else is in it.

Host telemetry belongs on the same seam and is deliberately not here. roost already ships a per-node reporter with launchd and systemd installers; a second set on the same box would be two answers to one question. The dokku guide points at roost's installer instead.

Tearing a stack down

hatchery stack rm <name>                        # shows the destroy plan, changes nothing
hatchery stack rm <name> --yes --confirm <name>  # actually destroys it

The name has to be repeated back, because this is the one action with no undo. The tofu directory and the config files are left on disk: they hold the state file and real secrets, and forgetting a declaration should not also delete the only record of what was there.

Looking at one service

Each service row expands. logs reads dokku logs (colour codes stripped, lines classified by severity). config shows what it is actually running with, secrets replaced by a fingerprint before the value leaves the process, alongside the contract issues config audit would report. deploy renders the plan as a diff with its tally, and only then offers to apply.

Confirmation is enforced on the server

The browser dialog that asks you to type a service's name is a convenience. It is not the control, because anything can post to the endpoint. So every mutating request carries the name of what it is about to change, and the server refuses a mismatch:

$ curl -XPOST localhost:7878/api/lifecycle -d '{"stack":"mwlab","service":"mwlab","action":"restart","confirm":"wrong"}'
{"error":"confirmation did not match; expected 'mwlab' to change it"}

Applying a deploy to a production stack is refused outright and stays on the CLI. The browser is the wrong place for the one action with no undo.

Binding and tokens

The default bind is 127.0.0.1, which nothing off the machine can reach, so no token is needed to get started. Binding anything else requires --token and is refused without one — this process holds SSH access to every stack it manages, so an open port is an open shell by proxy.

hatchery serve                                   # loopback, no token
hatchery serve --bind 0.0.0.0 --token "$(openssl rand -base64 24)"

The token is compared without an early exit, so how long the check takes says nothing about how much of it matched.

service new authors a service that does not exist yet — the declaration, its image variable, its config, and its manifest entry:

  write paylab2.tf
  append to variables.tf
  write paylab2.config.json
    APP_URL                  composed
    DATABASE_DB              composed
    DATABASE_PASSWORD        NEEDS VALUE
    GATEWAY_ADMIN_TOKEN      minted (32 bytes)
    KEYPAIR_JWKS             minted (RSA-2048, RS512)

  3 key(s) need values before this will boot:
    DATABASE_PASSWORD — the database role already exists; a new password would not connect

Where secrets come from

Until now they came from nowhere: the values were set by hand on the box once, and everything since — pull-mwlab-config.sh, then config sync — copied them back down. That works for a service that already runs and has nothing to say about one that does not.

So each declared key gets an origin, and the distinction is the whole point:

Origin Meaning
minted hatchery generated it; nothing else has a claim on the value
shared copied from a sibling, because the value has to match across the stack
composed derived from what the manifest already declares
NEEDS VALUE only a person or a third party has it

Three rules, each from the lab rather than from a preference. A signing key is shared when the stack already has one — the services verify each other's tokens, so a fresh key is a key nothing accepts — and minted only when there is no sibling to share with. Database credentials are never invented, because the role exists before the service does; a minted password produces a service that cannot connect. Third-party credentials are never invented, and saying so is more useful than a placeholder that looks filled in.

KEYPAIR_JWKS is minted as RSA-2048/RS512 rather than an elliptic curve, because that is what the live keys are. CryptoKit has no RSA, so hatchery shells out to openssl and reads the PKCS#1 structure directly — nine DER integers that are exactly a private RSA JWK's fields.

Not everything is an environment variable. Stripe credentials for the lab live in the database, so the origins above cover the config half of the problem and not the whole of it.

Keeping the backup current

The files holding those secrets cannot be committed, and cannot be gitignored and forgotten either. A state directory marked with .age-recipient keeps an encrypted copy — secrets.tar.age — produced by its own seal.sh, and that archive is what version control actually stores.

The mechanism was fine and the workflow was not. Sealing was a separate command a person had to remember after hatchery had already written the file, so a scaffolded stack left a freshly minted signing key on exactly one disk, and nothing said so for a day.

So hatchery seals for you. Any command that writes a secret — stack new, service new, config set, config sync, a deploy --apply that rewrites terraform.tfstate — re-seals the directory afterwards and says that it did. The browser's config editor does the same.

$ hatchery state status
/Users/you/infra-state
  last sealed  2026-08-06T12:20:45Z
  ok           every secret on disk is in the backup

state status compares SHA-256 hashes against secrets.manifest, so it never decrypts and needs no age identity — cheap enough to run on every check. It reports three things: a secret that was never sealed, one that changed since it was, and one still in the archive after being removed from disk. hatchery state seal re-seals by hand when you want it.

Hatchery runs the directory's own seal.sh rather than reimplementing it. That script decides what counts as a secret and which key it encrypts to; a second implementation drifting from it is precisely how a file ends up believed-sealed and absent.

Sealing never fails the command that triggered it. The config write already succeeded, and reporting it as a failure would invite someone to write the value again — so a seal problem is reported as a warning beside the result.

Committing and pushing the sealed archive is still yours to run. The archive lands in the working tree; getting it off the machine is a git operation on your repository, and hatchery does not make those on your behalf.

config audit checks what each service is running with rather than what a file claims:

mwlab  [dokku]
  mwlab: 21 keys, 0 error(s), 0 warning(s)
  paylab: 10 keys, 0 error(s), 0 warning(s)
  comlab: 9 keys, 0 error(s), 0 warning(s)

The distinction matters. dokku config:set merges rather than replaces, so a key set by hand never reaches the declared file and nothing reports the difference. The lab had exactly that: three keys were live on the app and absent from the file, and only reading the app found them.

status asks each service what it says about itself and composes the stack:

mwlab  [dokku]  responding
  mwlab                   responding   88ms    no health endpoint
  paylab                  responding   85ms    HTTP 200, no readiness report
  comlab                  responding   85ms    HTTP 200, no readiness report

The states run worst to best: unreachable, degraded, responding, ready. A stack reports the worst state among its services. responding means the service answered without a readiness report, which is what an older image looks like — better than silence, worse than an answer we can read. The exit code fails on unreachable or degraded only, so an image without a health route does not fail a script.

Two details that came from the lab rather than from a document. A dokku service is probed at the box with a Host header, because a lab vhost usually has no public DNS record. And the readiness path follows the estate convention rather than a path hatchery invented: /health for everything the shared microservice template generates, /healthz for gsx-gateway.

deploy moves a service to a new image through the declaration that owns it:

mwlab: mwlab_image
  mhehmsoth/mwserver2:arm64-0630f31-health -> mwserver2:arm64-abc1234
  manifest updated to mwserver2:arm64-abc1234
  tofu plan: changes pending
  ...
  nothing applied; re-run with --apply --yes

This is the part that most wants to be a shortcut and most must not be. The image is a tofu-declared attribute, so setting it at the box would leave the declaration stale and tofu plan permanently dirty — the same two-owners mistake config sync exists to undo. So deploy writes the tofu variable, runs tofu plan -detailed-exitcode, and shows what that would do. Nothing is applied unless --apply --yes says so.

Three things it will not do quietly:

  • If the plan stops evaluating after the write, the variables file is put back and the failure is reported. A configuration that no longer parses blocks every other change to the stack, which is worse than an undeployed image.
  • The manifest moves only after tofu agrees the write evaluates. Writing it first would leave the declaration claiming an image that never planned.
  • If the manifest and the tofu variable already disagreed, it says so before overwriting — that means someone moved the image outside hatchery.

The edit to variables.tf is textual rather than a parse-and-reprint, because the comments in that file explain how each image was built and where it came from. Exactly one line changes.

Why this exists

A stack's configuration currently lives in at least five places that disagree with each other: a DO app-spec template, gitignored dokku config dumps, .auto.tfvars.json files, the DO console, and a password manager. Nothing reconciles them, and nothing tells you whether a deployed app is actually serving — only whether its last deploy succeeded.

hatchery config validate is the first answer to that. It encodes the environment contract per service and backend, and it already catches a real latent failure: the 2026-07-28 cutover retired the discrete DATABASE_HOST / DATABASE_PORT / … keys on App Platform, so a lab config promoted to production as-is would not boot.

Design

Layering

All logic lives in HatcheryKit. Frontends are thin, so everything of consequence is reachable by tests. A future macOS GUI is a view layer over the same types — it does not get its own logic.

State ownership

hatchery owns the declaration — a committable manifest recording which stacks should exist, which services they run, and where each service's config sidecar lives.

It deliberately does not own live state. What is actually running is queried from the backend on demand. The two are kept apart because the alternative has already bitten us: the mwlab OpenTofu state is local-only and unbacked-up, was once recovered from an ephemeral scratch directory, and losing it would orphan three running apps.

Secrets live in sidecar files that are never committed. The manifest records where config lives, never what it contains.

Backends

Backend Target Create Read config back
dokku Self-hosted — the opi box and the Pi fleet, over SSH yes yes
appPlatform DigitalOcean App Platform — the production tenant plane yes no
aws AWS App Runner — a container, a URL, a health check yes no
cloudRun Google Cloud Run — App Runner's closest sibling yes no

hatchery once reported that App Platform could not be created, on the grounds that a spec is YAML applied through doctl. That was wrong: digitalocean_app is a first-class resource whose spec.service block takes an image, an environment, a port and a health check. What is genuinely blocked is reading a spec back — every SECRET value returns as EV[...] ciphertext — so config audit and config sync refuse for the three cloud backends rather than reporting drift they cannot see. Creating and reading are separate questions and only the second has a no.

Fly.io was considered and deferred. Its tofu provider is fly-apps/fly v0.0.23 and offers fly_app, fly_machine and fly_ip with no service abstraction and no health-check block, so a service would have to be assembled from machines by hand — a materially worse fit than the three above. flyctl would be the better route if it is wanted.

Each backend answers for itself. A provider supplies its own settings, its own readiness check and its own setup guide, so hatchery doctor --backend aws asks about credentials and a region while --backend dokku asks about a reachable box and an authorized key — and neither has to know the other exists:

$ hatchery doctor --backend aws
  AWS App Runner
  ok   tofu installed: OpenTofu v1.12.5
  FAIL aws cli: not found on PATH
       -> brew install awscli
  --   aws credentials: the aws cli is not installed
  --   aws region: the aws cli is not installed

hatchery setup --backend <name> prints what that backend needs from nothing, including the settings it takes. The dashboard shows the same guide behind Set up a backend, with a picker for each one.

Backend settings

What a backend needs is declared by the backend, not asked for separately by the CLI, the API and the wizard:

Backend Settings
dokku host, ssh_key
appPlatform region, token*
aws region, access_role_arn
cloudRun project, region

* secret: read from the environment at apply time, never written to the manifest.

hatchery stack new crlab --backend cloudRun --tofu-dir ~/infra-state/crlab \
  --set project=my-proj --set region=europe-west1

Before this, --host was a dokku concept every other backend ignored, a region was a bare parameter threaded through three call sites, and the page carried a needsHost flag so it could special-case one backend. Adding a provider meant editing all of them; now it means declaring an array. A missing required setting is refused by name, and so is a key the backend does not have:

$ hatchery stack new crlab --backend cloudRun --tofu-dir …
Error: cloudRun needs project; pass --set <key>=<value>, or run `hatchery setup --backend cloudRun`

$ hatchery stack new crlab --backend cloudRun --tofu-dir … --set projekt=typo
Error: cloudRun has no setting 'projekt'; it declares: project, region

Nothing here assumes a particular box. Which settings exist, whether one of them is an SSH target at all, and whether a value is stored or read from the environment are all the backend's answer to give.

App Runner rather than ECS or Fargate for the first AWS target, because hatchery's model of a service is an image, some environment, a domain and a health path — which is what App Runner takes. Fargate would need a VPC, subnets, a load balancer and target groups generated alongside every service, none of which hatchery has an opinion about. ECS can arrive as its own backend rather than as a flag on this one.

The AWS backend has not been applied against a live account. The generated configuration passes tofu validate against the real hashicorp/aws provider schema and tofu fmt -check, which means the resource shape and attribute names are right — not that a deploy succeeds. Treat the first real apply as the test.

The two are not interchangeable. App Platform dropped discrete DATABASE_* keys in the connection-string cutover; the lab still runs pre-cutover images, so the same keys remain legal there. EnvContract encodes that difference rather than pretending one contract fits both.

Neither AWS nor App Platform is implemented as an action backend yet — EnvContract knows App Platform's config contract, but reading live config, lifecycle, and deploy are all dokku-only today. AWS is wanted ahead of DigitalOcean; both are wanted eventually.

They arrive as implementations behind a protocol rather than as branches inside each verb, so a new provider is a new file rather than an edit to seven of them.

Relationship to roost

roost owns machines. hatchery owns stacks.

roost handles the host plane: the box itself, dokku, the Cloudflare tunnel, route publishing, node telemetry. It never learns what MWServer is.

hatchery handles the application plane: the stack definition, the environment contract, images, database wiring, lifecycle, application-level health.

When hatchery needs a URL to exist it asks roost (roost route <sub>) rather than reimplementing route publishing. hatchery can be installed as roost-hatch so roost's plugin dispatch picks it up.

Where they actually overlap today

That division reads cleanly and is not yet true in the code. Both tools drive the same box over the same SSH connection, and three commands do the same work:

roost hatchery
logs ssh dokku@h logs <app> -n 200 ssh dokku@h logs <app> --num 200
restart ssh dokku@h ps:restart <app> the same command
read config config:show config:export --format json

Two front doors to one command is harmless. Config writes are not.

roost config <app> KEY=VALUE runs dokku config:set on the box. hatchery deliberately refuses to do that: it writes the declaration and lets tofu apply, so that tofu stays the single owner of what the app runs with. A value set through roost is therefore invisible to the declaration, and tofu plan starts describing something that is no longer true.

That is exactly the drift hatchery config audit and config sync exist to find — the lab had three such keys live on mwlab and absent from its file, which is what prompted those commands. So the two tools do not merely duplicate here; they disagree about who owns config, and roost wins silently on the box.

Until that is settled in code rather than in this file: prefer hatchery config set over roost config <app> K=V, and run hatchery config audit after any use of the latter.

What hatchery does not rebuild

MacWorkStack-infra already solves local dev (compose.yaml + db/init/) and tenant creation (new-tenant.sh — key generation, database and pool creation, spec render, validate, create, poll). hatchery shells out to those rather than redesigning them.

Two deploy behaviours it must respect:

  • An env-only spec update reuses the already-resolved image digest and does not re-pull the tag. Only doctl apps create-deployment re-resolves.
  • Verify a deployment by digest, never by tag.

Development

swift build
swift test