Fly.io
Single-machine app hosting for druthers-gsx — dev→staging, main→prod, scale-to-zero.
Goals
Run a low-traffic ranked-choice-polling app (druthers-gsx) across two environments (prod druthers-gsx, staging druthers-gsx-staging) with automatic CI-driven deploys, without paying for always-on compute. Dogfooded repeatedly 2026-08-02/03: shipping an image-clear feature through the full pipeline, a staging-only entitlement-override feature, and two live bug investigations against the deployed staging app.
Effectiveness
Recommended for this shape of workload. Once the pipeline is set up, a green push to dev or main is the entire deploy story — no manual flyctl deploy in the common case. Scale-to-zero means an idle side project costs nothing between visits, and cold starts were fast enough (machine reachable in single-digit milliseconds to ~1.4s in the logs) that it never became a user-facing problem during testing.
What made it effective
flyctl logs -a <app> --no-tailstreams straight to the CLI with no dashboard round-trip — used it directly to rule out a server-side crash while debugging a client-rendering bug, in under a minute.- CI's post-deploy "enemy test" (a step literally named that, verifying the deployed server round-trips its object-storage bucket) catches storage misconfiguration before anyone hits the app.
- Branch-gated deploy jobs are provably correct, not just assumed: watching a CI run (
gh run watch) directly confirmeddeploy-prodreported "in 0s" and never executed on adev-branch push, rather than trusting the workflow file'sif:condition blindly. - Live curl against a deployed staging URL, cookie jar and all, worked identically to hitting localhost — no VPN, no special staging auth layer to fight through during verification.
- Poll state persists to Tigris (S3-compatible) object storage per app, so redeploys and scale-to-zero cycles don't lose data — confirmed via
persisting polls to s3://druthers-staging?region=autoin the boot log.
Friction / pain points / surprises
flyctl isn't on PATH by default. It lives at ~/.fly/bin/flyctl; every fresh shell needs export PATH="$HOME/.fly/bin:$PATH" or an alias, or the command silently isn't found.
flyctl apps list labels the prod app "suspended," not "scale-to-zero-idle." At a glance this reads like an incident, not the expected steady state of a healthy low-traffic app between visits.
CI carries a live deprecation warning that's easy to ignore because it still passes. A gh run watch on staging surfaced: "Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/checkout@v4, actions/setup-go@v5, actions/setup-node@v4, pnpm/action-setup@v3." Green build, so nothing forced the fix — it's the kind of thing that only gets addressed when someone happens to be watching the log, which is exactly what happened here.
Scale-to-zero cold-start logs are indistinguishable from a crash-restart loop unless you read timestamps carefully. A tail of flyctl logs shows repeated "Starting machine → ... → Sending signal SIGINT → reboot: Restarting system" cycles roughly every 6-90 minutes; this is auto_stop_machines doing its job, not instability, but it looks alarming out of context.