Skip to content

Deploy Fly Machines Offline

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/tutorials/local-fly/.
In `examples/local-fly`, run `npm install` and `npx chant run fly --no-record`. The run boots mudflaps and builds, applies and verifies the app there, then tears it all down.
Run it two more times and show me the output of each run.
Keep the local endpoint in `ops/fly.op.ts` and set no `FLY_API_TOKEN`.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

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 against a local emulator with no Fly account, credentials or cost, and deploys to a real Fly org by dropping a single override.

ResourceSerializes toApplied byTarget
App + Machineflaps 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 calls the flaps REST API directly, without flyctl or a 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.

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, as the ownership marker the owned-only prune reads back.

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).

  • Node.js and npm.
  • Docker. The Emulator phase (flapsUp) boots the mudflaps container; it is the only step that shells out, which is why Docker is the sole prerequisite.
Terminal window
cd examples/local-fly
npm install
chant run fly

The Op runs in-process 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 container

Every phase is a modeled activity, not a shell script. Add --json for machine-readable output.

With flyApply, the platform holds the state instead of a local file. Three re-runs show it:

  • Re-apply is a no-op. A second chant run fly finds the machine unchanged and skips it, since flyApply compares the desired config against the live machine.
  • An image change updates in place. Edit config.image in src/infra.ts and re-run; flyApply leases the machine, updates it, and waits the new instance_id to started.
  • Removing the machine prunes it. Delete the web machine and re-run with prune on (flyDeploy({ app: "local-fly-demo", prune: true })), and the owned-only prune destroys it, because it carries the managed-by: chant marker. A machine created directly in mudflaps, without that marker, is left untouched.

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 });
Terminal window
export FLY_API_TOKEN=... # a Fly deploy token
chant run fly

endpoint: 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. Only the endpoint and the token differ: mudflaps accepts unauthenticated calls, and 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.

chant