Deploy Fly Machines Offline
The local-fly
example is the smallest complete Fly deploy: one App and one Machine,
authored declaratively and applied straight to the Machines API. It runs end to
end against a local emulator — no Fly account, no credentials, no cost — and
deploys to a real Fly org by dropping a single override.
| Resource | Serializes to | Applied by | Target |
|---|---|---|---|
| App + Machine | flaps create bodies (JSON) | flyApply (direct Machines API) | mudflaps (:4280) |
Fly’s control plane is the Machines API (“flaps”). There is no server-side
declarative apply and no state file, so flyApply does the reconcile
itself: GET-then-create per resource, then poll /wait until each machine
reaches started. It speaks the flaps REST API directly — no flyctl, no
shell-out. The local target is mudflaps, a stateful fake of the same API, so
the applier runs against a real flaps-shaped control plane offline.
The infra
Section titled “The infra”src/infra.ts is one App and one Machine, authored as typed resources:
import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
// org_slug is required by the Machines API. Fly.OrgSlug resolves from FLY_ORG at// build time, default "personal" offline.const app = new App({ name: "local-fly-demo", org_slug: Fly.OrgSlug });
const web = new Machine({ name: "web", region: "iad", config: new MachineConfig({ image: "flyio/hellofly:latest", guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }), }),});
export { app, web };build:fly serializes these into the create bodies flyApply POSTs — the App
into POST /v1/apps { app_name, org_slug }, the Machine into
POST /v1/apps/local-fly-demo/machines { name, region, config }. The serializer
stamps managed-by: chant into config.metadata on its own — the ownership
marker the owned-only prune reads back.
The op
Section titled “The op”ops/fly.op.ts is one line — the flyDeploy composite:
import { flyDeploy } from "@intentius/chant-lexicon-fly";
export default flyDeploy({ app: "local-fly-demo" });flyDeploy lays out Emulator → Build → Apply → Verify → Teardown and wires
each phase to a fly activity. The Apply step’s flaps endpoint defaults to local
mudflaps (http://localhost:4280).
Prerequisites
Section titled “Prerequisites”- Node.js and
npm. - Docker. The
Emulatorphase (flapsUp) boots the mudflaps container; it is the only step that shells out, which is why Docker is the sole prerequisite.
Run it
Section titled “Run it”cd examples/local-flynpm install
chant run flyThe Op runs in-process (no Temporal server) through five modeled phases:
[phase] Emulator ✓ flapsUp boots ghcr.io/intentius/mudflaps on :4280[phase] Build ✓ chantBuild(script=build:fly) writes dist/fly.json[phase] Apply ✓ flyApply(planPath=dist/fly.json) created app + machine, waited to started[phase] Verify ✓ httpCheck(.../machines) machine reached started[phase] Teardown ✓ flapsDown removes the mudflaps containerEvery phase is a modeled activity, not a shell script. Add --json for
machine-readable output.
What the reconcile proves
Section titled “What the reconcile proves”The point of flyApply is that the platform holds the state, not a local file.
Three re-runs show it:
- Re-apply is a no-op. A second
chant run flyfinds the machine unchanged and skips it —flyApplycompares the desiredconfigagainst the live machine. - An image change updates in place. Edit
config.imageinsrc/infra.tsand re-run;flyApplyleases the machine, updates it, and waits the newinstance_idtostarted. - Removing the machine prunes it. Delete the
webmachine and re-run with prune on —flyDeploy({ app: "local-fly-demo", prune: true })— and the owned-only prune destroys it, because it carries themanaged-by: chantmarker. A machine created directly in mudflaps, without that marker, is left untouched.
Run it against real Fly
Section titled “Run it against real Fly”The same op deploys to a real Fly org with one change — drop the local endpoint override and give it a token:
export default flyDeploy({ app: "local-fly-demo", endpoint: null });export FLY_API_TOKEN=... # a Fly deploy tokenchant run flyendpoint: null drops the local override, so flyApply falls through to
FLY_FLAPS_BASE_URL (or Fly’s default https://api.machines.dev) and sends
Authorization: Bearer $FLY_API_TOKEN. The emulator is just an endpoint swap —
mudflaps accepts unauthenticated calls, real Fly needs the token. Real mode boots
no container (no Docker required); the Apply step’s own /wait on started is
the verification there.