Use when deploying or operating an app on Fly.io — writing fly.toml, placing Machines in regions near users, attaching Volumes, managing secrets, or picking a scaling lever (autostop/autostart, scale count, fly-replay). NOT choosing which host to deploy on (that is `deployment`), NOT a git-push PaaS with no regions model (that is `railway`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill fly-io --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Fly Io?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-fly-io)More formats (shields.io, HTML) on the badges page.
---
name: fly-io
description: "Use when deploying or operating an app on Fly.io — writing fly.toml, placing Machines in regions near users, attaching Volumes, managing secrets, or picking a scaling lever (autostop/autostart, scale count, fly-replay). NOT choosing which host to deploy on (that is `deployment`), NOT a git-push PaaS with no regions model (that is `railway`)."
tags: [fly-io, deployment, machines, regions, scaling]
recommends: [docker, scaling, postgresdb, railway, domains-dns]
origin: risco
---
# Deploy on Fly.io
You are deploying an app to Fly.io: a `fly.toml`, Machines (Firecracker microVMs) placed in regions close to users, optional region-pinned Volumes, secrets, and the right scaling lever. Get the mental model right first, then the config follows. If none of that placement control matters, ../railway/SKILL.md is the git-push PaaS with no Machines/regions model.
## Mental model
- **App** is the logical unit. It owns a name, a `primary_region`, and config in `fly.toml`. Why: every command targets an app.
- **Process groups** (`[processes]`, e.g. `web`, `worker`) split one image into roles. Why: a web group takes traffic, a worker group does not — they bind services and VMs separately.
- **Machines** are Firecracker microVMs running your image. Each runs in exactly one region. Why: latency and volumes are per-Machine, so placement is the whole game.
- **Fly Proxy** is the anycast front door. It routes a request to the nearest running Machine, can start a stopped one, and obeys `fly-replay` headers. Why: it is what makes "global" cheap — you do not run a load balancer.
- **Volumes** are local NVMe disks pinned to one Machine in one region. No replication. Why: this single fact dictates every stateful architecture decision below.
## Deploy fast (4 commands)
```bash
fly launch # detects framework, generates fly.toml + Dockerfile, creates the app
fly secrets set DATABASE_URL=postgres://... # restarts every Machine; never put this in [env]
fly deploy # builds image, runs release_command, rolls out Machines
fly scale count 2 --region iad,ams # place Machines in Virginia + Amsterdam
```
`fly launch` is interactive and writes a starter `fly.toml`. Treat that file as a draft — review it against the next section before the first real deploy. Run `fly status` and `fly logs` after any deploy.
## A fly.toml that works
```toml
app = "my-api"
primary_region = "iad" # 3-letter region code: iad, ord, ams, syd, gru, nrt...
[build]
# dockerfile = "Dockerfile" # Fly builds from your Dockerfile; see ../docker/SKILL.md
[deploy]
release_command = "npm run migrate" # one-shot Machine that runs BEFORE the new version goes live
strategy = "rolling" # rolling | bluegreen | canary | immediate
[processes]
web = "node server.js"
worker = "node worker.js"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop" # "off" | "stop" | "suspend" — set WITH auto_start_machines
auto_start_machines = true
min_machines_running = 0 # 0 = scale to zero; honored only in primary_region
processes = ["web"]
[http_service.concurrency]
type = "requests"
soft_limit = 200 # Proxy starts spreading load past this
hard_limit = 250 # Proxy stops sending past this
[[vm]] # formerly [[compute]]
size = "shared-cpu-1x"
memory = "512mb"
cpu_kind = "shared" # "shared" | "performance"
processes = ["web"]
[[mounts]]
source = "data" # volume NAME, created with `fly volumes create data`
destination = "/data"
processes = ["web"]
initial_size = "1gb"
```
Full field surface (`[[services]]` vs `[http_service]`, health checks, `[[statics]]`, `[[files]]`, all VM sizes, `[restart]`, `[metrics]`) lives in `references/fly-toml.md` — read it when you need a key that is not above. Custom domains, certs and registrar-level DNS are ../domains-dns/SKILL.md.
## Regions: place Machines near users
Pick the branch first, then run the commands.
| Your app is... | Strategy | How |
| --- | --- | --- |
| Stateless (no local disk; DB elsewhere) | Replicate the Machine into more regions | `fly scale count 2 --region iad,ams,syd` |
| Stateful with a Volume | Keep writes in `primary_region`, add read replicas + `fly-replay` | see `references/multi-region.md` |
| Needs one extra box now | Clone a single Machine (gets a fresh volume) | `fly machine clone <id> --region syd` |
```bash
fly platform regions # list region codes + names
fly scale count web=2 --region ams # per-process, per-region count
fly scale show # what runs where, right now
```
Rules:
- A request with no pinned region goes to the **fastest Machine for that caller** via anycast — multi-region is mostly "run Machines in more places."
- `fly scale count N --region a,b` is the per-region count, not a total. Why: `count 2 --region iad,ams` means 2 in *each*, i.e. 4 Machines.
- If any target region is **out of capacity, the whole scale op fails** — no partial placement. Retry with fewer regions or a different code.
- "Slow for users in Sydney, app runs in iad" => add `syd`, not a bigger VM. Latency is distance, not CPU.
## Volumes
A Fly Volume is a local NVMe disk **pinned to one Machine in one region**. There is **no automatic replication** between volumes. Encrypted at rest by default (`--no-encryption` to opt out — almost never do).
```bash
fly volumes create data --region iad --size 3
fly volumes list
```
- **One volume attaches to one Machine.** Two Machines cannot share a volume. Why: it is block storage on one host, not a network filesystem.
- `fly scale count` on a group with a `[[mounts]]` creates a **new empty volume per new Machine** — it does **not** copy your data. This is the #1 stateful gotcha.
```toml
# Bad: expecting two web Machines to "share" /data — they each get their own empty disk
[[mounts]]
source = "data"
destination = "/data"
processes = ["web"] # then `fly scale count web=3` => 3 separate, unsynced disks
```
```toml
# Good: one writer with the volume; replicas are stateless and read via the DB/fly-replay
[[mounts]]
source = "data"
destination = "/data"
processes = ["writer"] # a single-Machine process group; scale `web` separately, stateless
```
Replication is **your app's job** (LiteFS, app-level streaming, or a managed DB), never the volume's. See `references/multi-region.md`.
## Secrets
```bash
fly secrets set STRIPE_KEY=sk_live_... SESSION_SECRET=... # one rollout
fly secrets list # shows NAME + digest + timestamp — never the value
fly secrets unset OLD_KEY
```
- `fly secrets set` **updates every Machine and restarts them** — it resets the ephemeral filesystem. Why: batch your sets into one command so you trigger one rollout, not five.
- Secrets arrive as **environment variables** in the guest. Read `process.env.STRIPE_KEY`.
- Need a secret as a *file* on disk (a cert, a service-account JSON)? Use `[[files]]` with `secret_name` — see `references/fly-toml.md`.
```toml
# Bad: secret baked into the image / committed config
[env]
STRIPE_KEY = "sk_live_51H..." # in git, in the image layers, leaked
```
```bash
# Good: out of the repo, out of the image, encrypted in Fly's vault
fly secrets set STRIPE_KEY=sk_live_51H...
```
Treat secret hygiene as non-negotiable — see ../secure-coding/SKILL.md.
## Scaling: pick the right lever
| Lever | What it does | Reach for it when |
| --- | --- | --- |
| `auto_stop_machines` / `auto_start_machines` | Fly Proxy stops/starts a **pre-created pool** by load; never creates/destroys | Bursty or idle traffic; cut cost on quiet hours |
| `fly scale count` | You set how many Machines exist per region/process | Steady baseline capacity; geographic spread |
| `fly-autoscaler` (superfly/fly-autoscaler) | Scales **Machine count** off any Prometheus metric | Queue depth / custom-metric driven autoscaling |
| `fly-replay` header | App returns `fly-replay` so Proxy replays the request elsewhere | Forward writes to primary region; route by tenant |
Key distinction: **autostop ≠ autoscaler.** Autostop only toggles Machines that already exist; it never changes the count. The metrics autoscaler is what actually adds/removes Machines. Set `auto_stop_machines` and `auto_start_machines` **together** — configuring one without the other is undefined behavior.
`fly-replay` is the multi-region write-forwarding pattern: read-replicas serve local reads, a write replies with `fly-replay: region=<primary>` and the Proxy re-runs the request there. Full header forms and the primary/replica split are in `references/multi-region.md`. These are the Fly levers only; platform-agnostic scaling theory (queues, sharding, load shedding) is ../scaling/SKILL.md.
## Cost & HA
- **min 2 Machines for HA.** A single Machine = a single point of failure; Fly recommends ≥2 per group in production.
- **Stopped Machines are cheap** — you pay for rootfs/volume storage, not running compute. So a warm pool with `auto_stop_machines = "stop"` is the default cost play.
- **Scale to zero** (`min_machines_running = 0`) trades cost for a **cold start** on the next request. If the first-request latency hurts, set `min_machines_running = 1` to keep one warm. Note: `min_machines_running` is honored **only in the primary region**.
- `"suspend"` resumes faster than `"stop"` (keeps memory snapshot) but is supported on fewer setups — verify before relying on it.
## Verify
After writing or editing a `fly.toml`, run the checker:
```bash
scripts/verify.sh path/to/fly.toml # defaults to ./fly.toml
```
It prefers `fly config validate` when flyctl is on PATH, else does structural checks (app, primary_region, an internal_port, and the autostop-pair lint). Read-only; exits nonzero on any FAIL.
## Anti-patterns
| Anti-pattern | Why it bites | Do instead |
| --- | --- | --- |
| Secrets in `[env]` or the Dockerfile | Committed to git, baked into image layers | `fly secrets set` |
| `fly scale count` on a `[[mounts]]` group expecting shared data | Each new Machine gets an empty volume | Single writer + stateless replicas + DB/fly-replay |
| Setting only `auto_stop_machines` | Half-configured autostop is undefined | Set start + stop keys together |
| Assuming autostop changes Machine count | It only toggles a fixed pool | Use `fly scale count` or `fly-autoscaler` |
| One Machine in production | No HA; a host blip = downtime | ≥2 Machines per group |
| Bigger VM to fix far-away latency | Distance, not CPU, is the cost | Add a Machine in the user's region |
| Volume in a different region than its Machine | Cannot attach across regions | Create the volume in the Machine's region |
| Treating Fly Postgres as managed | Fly Postgres is unmanaged; you operate it | Route to it here; operate it via ../postgresdb/SKILL.md |
| `min_machines_running` in a non-primary region | Ignored outside primary | Keep warm capacity via `scale count` there |
| `fly deploy` with no `release_command` for a schema change | New code hits an old schema mid-rollout | `release_command` runs the migration first |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!