Fly.io resource plugin for formae. This plugin enables Formae to manage Fly.io resources using the Machines REST API — apps, machines, volumes, secrets, certificates, IP addresses and Managed Postgres.
Requires formae 0.89.0 or newer — the schema uses formae.ValueSource, the
0.89.0 secret/generator binding type.
This plugin supports 14 Fly.io resource types across 2 services. See
schema/pkl/ for field definitions — every class carries the API
behaviour it was written against, including what is deliberately not
implemented.
| Resource Type | Ops | Description |
|---|---|---|
FLY::Apps::App |
CR_D | Fly app — the namespace every other resource lives in. Free until a machine runs |
FLY::Apps::Machine |
CRUD | One Firecracker VM running one container image. Bills per second while running |
FLY::Apps::Volume |
CRUD | Persistent volume attached to at most one machine |
FLY::Apps::VolumeSnapshot |
CR_d | Point-in-time snapshot of a volume |
FLY::Apps::Secrets |
CRUD | All of an app's secrets as one resource, injected into machines at boot. A formae.Secret: an entry is readable as bag.res.secretValue.at("KEY") |
FLY::Apps::SecretKey |
CRUD | App-scoped KMS key material for the encrypt / sign endpoints |
FLY::Apps::Certificate |
CR_D | ACME certificate for a custom hostname |
FLY::Apps::IPAddress |
CR_D | IP address assigned to an app. Required for the app to be reachable |
FLY::Postgres::Cluster |
CR_D | Managed Postgres cluster. A real always-on database, no free tier |
FLY::Postgres::Database |
CR_D | Database inside a Managed Postgres cluster |
FLY::Postgres::User |
CRUD | Role inside a cluster. role is the only mutable field |
FLY::Postgres::Attachment |
CR_D | Attaches a Fly app to a cluster, injecting a DATABASE_URL secret |
FLY::Postgres::Extension |
CR_D | Postgres extension enabled in one database |
FLY::Postgres::Backup |
CR_d | Backup of a Managed Postgres cluster |
Ops is Create / Read / Update / Delete. Every type additionally implements
Status and List, so all fourteen take part in discovery.
_— no Update. The API has no update endpoint for that resource, so changing a field replaces the resource rather than mutating it. Each schema class says which endpoint is missing.d— Delete is a no-op that reports success. Fly exposes no delete for volume snapshots or Postgres backups; both expire under a retention policy.
Everything is the Machines REST API on one transport — no GraphQL client. That
is the declarative REST surface exhausted; what remains (organizations,
WireGuard peers, egress IPs, Upstash Redis, Tigris buckets, tokens) is
GraphQL-only. The runtime verbs (start, stop, restart, suspend, exec,
signal, lease, wait) are REST and deliberately unimplemented: they are
operations to perform on a resource, not state to declare.
Behaviour that will surprise you otherwise:
- Use the organization's real slug, not
"personal". Fly acceptspersonalon create and reports the real slug on read; sinceorgiscreateOnlythat is permanent drift. The plugin refuses it and names the slug to use. - An app is unreachable from the internet until it has an
IPAddress.fly deployallocates one implicitly; the raw Machines API does not.shared_v4andv6are free, a dedicatedv4is billable. - A running machine does not pick up a changed secret until it restarts. The plugin does not restart machines for you — a service interruption should not be an invisible side effect of a secret update.
Machine.stateis an output, not desired state. Setting it does nothing: fly-proxy stops and starts machines itself underautostop/autostart, so reconciling it would fight the platform.regioniscreateOnlyon machines and volumes. Fly has no move operation, so a region change replaces the resource — and for a volume that means the data is gone.VolumeSnapshotandPostgres::Backupcannot be deleted. Fly exposes no delete endpoint; both expire under a retention policy. Their delete reports success and says so.Secrets.valuesis write-only. It is never echoed back, so value drift is still not detected — only an added or removed name. Wrap sensitive entries informae.value(x).opaqueto have them hashed at rest: opacity is per entry here, because formae derives it from a field's declared type and does not descend into map value positions.SecretKey.valueand the bag's read-onlydecodedValuesare typed opaque and hashed at rest whatever you write.FLY::Apps::Secretsis aformae.Secret. Read reveals the bag (GET /v1/apps/{app}/secrets?show_secrets=true) onto a read-onlydecodedValuesfield, so an entry is referenceable asbag.res.secretValue.at("KEY")— including theDATABASE_URLa Postgres attachment injects, which formae never wrote. Reveal happens on Read only; discovery lists names. A token that may list but not reveal still reads the bag, just without the values.
Configure a Fly.io target in your Forma file:
import "@formae/formae.pkl"
import "@fly/core/fly.pkl"
target: formae.Target = new formae.Target {
label = "fly-target"
config = new fly.Config {
org = "my-org" // required — the organization's real slug
region = "fra" // optional — default region for machines and volumes
// Optional: override the API endpoint
// baseUrl = "http://_api.internal:4280"
}
}org is required: listing apps (GET /v1/apps?org_slug=) and org-wide volume
discovery both need it, and it cannot be derived from a token. Machine discovery
goes through that app list too, one call per app. Fly's org-wide machine index
is not used: Fly documents it as "a point in time" where "recent machine
changes, including creations and destructions, may take time to propagate", and
in practice a machine created seconds ago is missing from it for minutes.
region is a default so a forma need not repeat it on every machine and volume;
a resource-level region wins. The authoritative region list is public and
needs no auth:
curl -s https://api.machines.dev/v1/platform/regions | jq -r '.Regions[].code'FLY_REGION is deliberately not used as a fallback — inside a Fly VM it means
"the region I am running in", which would make applies behave differently
depending on where the agent runs.
The plugin reads the API token from the environment, never from a forma or from
target config. Resolution order matches flyctl exactly, so a token that works
with fly works here:
export FLY_ACCESS_TOKEN="your-token" # checked first
export FLY_API_TOKEN="your-token" # fallback
export FLY_ORG="my-org" # organization slug~/.fly/config.yml is deliberately not read — the plugin runs inside the formae
agent, often in a container with no $HOME/.fly.
Which token types work:
| Token | Create with | Works? |
|---|---|---|
| Personal access token | fly auth token |
Yes — full surface. Short-lived; best for local development |
| Org token | fly tokens create org |
Yes, for everything in that org, including app create. Best for CI |
| Org read-only | fly tokens create readonly |
Read, List and discovery only. Writes fail 403 |
| App deploy token | fly tokens deploy |
No — scoped to one existing app, cannot create apps |
GET /v1/tokens/current reports what a token can do:
curl -s -H "Authorization: Bearer $FLY_API_TOKEN" \
https://api.machines.dev/v1/tokens/current | jqFLY::Apps::Secrets entries and FLY::Apps::SecretKey.value take
formae.ValueSource, so a credential need never be written into a forma. (On
SecretKey.value a generator draw type-checks but will not be valid key
material — Fly wants base64 sized for the keyType. Omit the field and let Fly
generate the key.)
Let formae draw it. Without a rotation the value is drawn once and never
changes — the replacement for minting a password at eval time and pinning it
with setOnce. With one, formae rotates on the cadence and moves every
destination bound to the generator together:
local sessionPw = new formae.PasswordGenerator {
label = "api-session-secret"
stack = appStack.res
rotation = new formae.RotationSpec { every = 30.d }
}
local apiSecrets = new fly.Secrets {
label = "api-secrets"
appName = api.res.name
values {
["SESSION_SECRET"] = sessionPw.gen.value
}
}Fly reads secrets back, too. FLY::Apps::Secrets is a formae.Secret whose
value is the bag, so an entry is reachable by key — including one Fly wrote
itself:
local pg = new fly.Attachment {
label = "api-db"
clusterId = cluster.res.id
appName = api.res.name // Fly injects DATABASE_URL into this app
}
local apiSecrets = new fly.Secrets {
label = "api-secrets"
appName = api.res.name
values { ["SESSION_SECRET"] = sessionPw.gen.value }
}
// Somewhere else entirely — another plugin, another stack:
// dsn = apiSecrets.res.secretValue.at("DATABASE_URL")The bag does not declare DATABASE_URL, and must not: Fly owns that key and the
two would fight over it. Reading it back is fine — values is the write side,
decodedValues the read side, and they are separate fields.
secretValue on a map-shaped secret is an accessor, not a value: .at(key) is
required and a bare secretValue will not type-check.
Or hand Fly a secret another provider holds. It is read live on every plugin call, so rotating it upstream takes effect without re-applying here:
values {
["DB_PASSWORD"] = dbSecret.res.secretValue // scalar secret
["API_KEY"] = vaultSecret.res.secretValue.at("api-key") // map-shaped
["TOKEN"] = appSecret.res.secretValue.json("creds.token")
}A reference is a handle, not a string: pass it whole, never interpolated.
Rotation inherits the Fly caveat noted above: a running machine does not pick up the new value until it restarts, and formae will not restart it for you.
See the examples/ directory for usage examples.
# Evaluate an example
formae eval examples/basic/main.pkl
# Apply resources — returns a command id; poll it to watch progress
formae apply --mode reconcile --yes examples/basic/main.pkl
formae command status <id> --output-layout detailed| Example | Shows | Costs anything? |
|---|---|---|
basic/ |
One publicly reachable Fly app: app, secret, machine, IPv4 + IPv6 | Yes — the machine bills per second |
fullstack-fly-supabase-vercel/ |
A three-tier app across Fly, Supabase and Vercel, wired with cross-plugin resolvables. Includes a two-provider variant | Yes — a Supabase project and a Fly machine |
There is no --watch flag in formae 0.89.0: formae apply returns as soon as
the agent accepts the command. Machine creates, and Managed Postgres creates in
particular, are asynchronous — follow them with formae command status.
Contributor setup, conformance testing and publishing are in
CONTRIBUTING.md. Design decisions live next to the code they
explain, in schema/pkl/core/fly.pkl and the provisioners under pkg/.
This plugin is licensed under the Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2).
Copyright 2026 Platform Engineering Labs Inc.