Skip to main content

Stopped Is Not Free: The Fly.io Preview-Environment Bill You Have to Delete

11 min readDora NodaDora Noda
Share
On this page

Every merged pull request in your repo is a preview environment nobody will ever open again. If those previews run on Fly.io Machines and your cleanup workflow ever skips a beat, they are also a line item on your invoice — month after month, for servers doing literally nothing. Stopping a machine is not the same as deleting it, and Fly bills the difference.

This post puts a number on that difference: a 30-day cost model for 10, 50, and 100 dormant previews, built from Fly's published prices, plus the teardown checklist that takes each cohort back to zero — on Fly, and on a self-hosted Kubernetes fleet where the same rot shows up as capacity instead of cash.

Stopping a preview does not make it free​

Three prices govern the dormant-preview bill, all confirmed in Fly's pricing update effective October 1, 2026: stopped-machine root filesystem at $0.15 per GB-month, provisioned volumes at $0.15 per GB-month, and volume snapshots at $0.08 per GB-month with the first 10 GB free each month (Fly.io pricing update). Each one has a detail that matters for previews.

First, the rootfs charge applies to used space, per stopped machine, with no deduplication. When Fly announced the charge, it clarified that billing counts only the used portion of the root filesystem — the example given was an 8 GB device with just 191 MB used — and a follow-up in the same thread confirmed that two stopped machines sharing one image are billed separately, not deduplicated (Fly.io community announcement). Ten stopped previews running the same image pay the rootfs line ten times. The rootfs itself is the assembled container image layers plus files written at runtime, which Fly keeps on the host for fast restarts — that is what you are paying to keep warm.

Second, volumes bill whether the machine is running or stopped. A preview with a 3 GB Postgres volume pays $0.45 a month for that volume from the day it is created until the day the volume is destroyed, and stopping the machine changes nothing.

Third, snapshots have billed since January 1, 2026 at $0.08 per GB-month after the first free 10 GB (Fly.io snapshot announcement). Fly takes automatic daily volume snapshots with multi-day retention, so every lingering preview volume drags a trail of snapshot storage behind it.

And nothing here times out. Fly keeps stopped machines — and their assembled root filesystems — around indefinitely so they can restart on demand, with no time-based garbage collection that reaps them; the rootfs charge runs until you destroy the machine (announcement thread). Park a preview, forget the destroy step, and the meter runs until you intervene.

The 30-day model: 10, 50, and 100 dormant previews​

Here is the bill for three dormant-preview cohorts over 30 days, using typical inputs: 2 GB of used rootfs per preview (a realistic full-stack image with runtime writes), half the previews carrying a 3 GB volume (the ones that needed a database), and snapshot footprint roughly equal to attached volume capacity with the org-level 10 GB free tier applied. The destroyed-preview column is the delta each cohort owes to a working teardown path.

CohortRootfs (used)VolumesSnapshots (over 10 GB free)30-day totalIf destroyed
10 stopped previews10 × 2 GB × $0.15 = $3.005 × 3 GB × $0.15 = $2.25(15 − 10) GB × $0.08 = $0.40$5.65$0.00
50 stopped previews50 × 2 GB × $0.15 = $15.0025 × 3 GB × $0.15 = $11.25(75 − 10) GB × $0.08 = $5.20$31.45$0.00
100 stopped previews100 × 2 GB × $0.15 = $30.0050 × 3 GB × $0.15 = $22.50(150 − 10) GB × $0.08 = $11.20$63.70$0.00

Two sensitivities decide where your fleet lands relative to these rows. Image weight moves the rootfs line linearly: slim distroless-style images around 0.5 GB of used rootfs cut it to a quarter, while heavy images with bundled toolchains or ML runtimes near 4 GB double it. And the volume mix dominates everything — a cohort where every preview mounts a database pays roughly triple the table's volume and snapshot lines, while a stateless cohort pays rootfs only.

Is $63.70 a month a crisis? No — running compute on the same 100 previews would cost an order of magnitude more, which is exactly why teams stop them and move on. That is the trap: the dormant bill is small enough to ignore and permanent enough to compound. A team shipping 20 PRs a week with a broken destroy step accumulates the 100-preview cohort in five weeks, then pays for it forever. The money is not the point so much as what it signals — a teardown path nobody verified, quietly failing in the background.

Why previews pile up instead of dying​

Fly has no native preview environments. The sanctioned pattern is the review-apps blueprint: a GitHub Actions workflow creates a Fly app per pull request and destroys it when the PR closes (Fly.io review-apps blueprint), commonly via the superfly/fly-pr-review-apps action. Two mechanisms turn this scripted setup into accumulation.

The first is auto-stop doing its job too well. Setting auto_stop_machines = "stop" is the default cost play on Fly: idle machines park themselves and cold-start on the next request. For production that is a feature. For previews it means a stale environment looks quiet and cheap in every dashboard while its rootfs, volumes, and snapshots keep billing. Nobody gets paged for a stopped machine, so nobody notices the destroy step never ran.

The second is the destroy step itself being the least-tested line in the workflow. The on-close job does not run when the workflow file changes mid-PR, when the close event comes from a fork the token cannot act on, when a force-push or retarget confuses the app-name mapping, or when someone closes the PR from a phone while Actions is degraded. Each miss leaves a full preview app behind: stopped machines, provisioned volumes, and a snapshot schedule still ticking. One missed destroy is noise. A systemic miss pattern is the 100-preview cohort.

Snapshots compound the damage silently. Daily automatic snapshots with multi-day retention mean each orphaned volume's billable footprint exceeds the volume itself, and raising retention toward the 60-day maximum for production safety raises the orphan bill too if preview volumes share the setting.

The Fly-side teardown checklist​

Four actions, in priority order, take any cohort to zero and keep it there.

1. Make destroy-on-close the primary path, and alert on its absence. The PR-close job should run fly apps destroy for the preview app unconditionally — destroy removes machines, volumes, and the app in one operation, which is what releases every line item at once. Then add the check most teams skip: a daily scheduled workflow that lists preview apps older than your merge SLA (say, 48 hours past PR close) and fails loudly — Slack message, issue, page — when it finds any. The destroy step is infrastructure; monitor it like infrastructure.

2. Run a scheduled sweeper, not just an event hook. Event-driven cleanup always has a gap. A weekly cron job that enumerates apps matching your preview naming pattern (pr-*), checks each against the GitHub API for a still-open PR, and destroys the orphans closes every gap class from the previous section at once. This is fifteen lines of shell around fly apps list and gh pr view, and it bounds the worst case to one week of drift.

3. Tune snapshot retention per environment class. If preview volumes inherit production's retention setting, split them. Short retention on preview volumes — or no volumes at all for previews that can run against a seeded ephemeral database — removes the fastest-growing line in the model. The 10 GB free tier covers a small team's preview snapshots entirely, but only if the footprint stays small.

4. Audit volumes quarterly. Volumes are the line item most likely to outlive their machine, because destroying a machine does not destroy its volume while destroying the app does. Any volume whose app is gone or whose machine has been stopped for months is a destroy candidate. Snapshot before destroying if you are cautious; but a preview volume for a merged PR is, by definition, reproducible from the repo.

The self-hosted Kubernetes equivalent​

On a cluster you own, the same failure mode bills in capacity instead of cash — which makes it easier to ignore and harder to attribute. The mapping from the Fly model to Kubernetes is direct, and so is the fix.

The unit of a preview is a namespace per PR, created on open and deleted on merge — the dominant pattern for hosting ephemeral environments on Kubernetes, where cleanup is literally kubectl delete namespace (pattern survey). But just as the Fly destroy step gets skipped, the namespace deletion gets skipped: the webhook fails, the controller restarts mid-reconcile, the PR merges from a client that never triggers the pipeline.

So the first control is the same shape as Fly's sweeper, with a Kubernetes-native enforcement option: a TTL janitor that deletes namespaces carrying a TTL annotation past their deadline. The pattern is well established — controllers like namespace-janitor reconcile on creation timestamp plus a ttl annotation and delete expired preview namespaces automatically — and platform teams with heavier needs get the same behavior from garbage collectors like Okteto's, which scale down or delete inactive namespaces and preview environments on policy.

The rootfs line maps to node image storage. Kubelets garbage-collect unused images only under disk pressure, between the image-gc-high-threshold (default 85%) and image-gc-low-threshold (default 80%) marks (Kubernetes garbage collection). On a fixed-cost fleet that means every preview image ever pulled sits on node disk until the node nearly fills — stale previews do not cost dollars, they cost headroom, and the symptom is an eviction or a disk-pressure alert months later rather than an invoice line today. Set explicit GC thresholds in the kubelet config, and treat preview-node disk usage as a metric with an alert, not a background detail.

The volume line maps to the PVC reclaim chain, and this is where Kubernetes previews rot fastest. Deleting a namespace triggers an asynchronous chain — PVC deleted, PV released, the CSI driver removing the backing store — but only when reclaimPolicy: Delete holds end to end and no finalizer stalls the chain (persistent volume reclaiming).

A stuck finalizer, an overloaded CSI driver, or a namespace force-deleted to unstick a deploy leaves the backing volume behind: the cluster objects are gone, so nothing lists the orphan, while the storage still exists. The fix is explicit: verify reclaim policy on the preview StorageClass, never force-remove namespace finalizers without finding the owning controller first, and run the same periodic orphan sweep as the Fly checklist — enumerate PVs and PVCs older than the preview SLA with no live namespace and delete them after a snapshot.

Framed as money: on a fixed-cost Hetzner-class fleet, none of this appears on an invoice. It appears as the month you add a node quarter early because preview images and orphaned volumes ate the headroom. The accounting differs; the discipline — delete, TTL, sweep — is identical.

Delete is the only free tier​

The throughline across both platforms is uncomfortable in its simplicity: every preview system has exactly one operation that returns cost to zero, and it is spelled delete. Stop, suspend, scale-to-zero, and auto-park all preserve state, and preserved state is what the meter measures — in dollars on Fly, in disk and objects on your own cluster. Build the destroy path first, monitor it like production, and sweep behind it on a schedule. The $63.70 cohort is not expensive; it is evidence.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex