Zero to Production: FastAPI on Fly.io with a YAML Config and GitHub Actions
You push to main, tests run, and a couple of minutes later your API is live. This post builds that pipeline end to end — one FastAPI app, one Docker image, one YAML config file, one GitHub Actions workflow. No database, no Redis, no extra services: just the app, deployed properly.
The Big Picture
git push → GitHub Actions → run tests → build image → deploy to Fly.io → live
Three moving parts:
- Your FastAPI app, packaged in a Docker image so it runs identically everywhere.
- Fly.io, which takes that image and runs it on small VMs called Machines — handling TLS, routing, health checks, and restarts.
-
GitHub Actions, which watches the repo and runs the deploy automatically on every push to
main.
The glue is a scoped deploy token: a Fly API token stored as a GitHub secret so the CI runner can deploy on your behalf — and nothing else.
A note on the config file before we start: Fly's docs default to fly.toml, but flyctl accepts TOML, YAML, or JSON — the structure is identical. We'll use YAML (fly.yaml) throughout. The only consequence is that since flyctl looks for fly.toml by default, we pass --config fly.yaml explicitly. That's a feature, not a chore: being explicit about which config you're deploying is exactly the habit that later scales into fly.stg.yaml / fly.prod.yaml multi-environment setups.
Step 0: Prerequisites
- A FastAPI project in a GitHub repo
- The
flyctlCLI (curl -L https://fly.io/install.sh | sh) - A Fly.io account (
fly auth signuporfly auth login)
Project layout:
myapi/
├── app/
│ ├── __init__.py
│ └── main.py
├── tests/
│ └── test_main.py
├── requirements.txt
├── Dockerfile
├── fly.yaml
└── .github/
└── workflows/
└── deploy.yml
And a minimal app with a health endpoint (we'll need it later — it's not decoration):
# app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
Step 0.5: requirements.txt
The Dockerfile in the next step installs from this file, so it needs to exist first. Minimal set for a FastAPI app served by uvicorn:
fastapi==0.115.6
uvicorn[standard]==0.34.0
uvicorn[standard] pulls in uvloop and httptools, which make it noticeably faster than the bare install — worth it even for a small API. If your app talks to a database or reads config from environment variables, you'd typically add:
pydantic-settings==2.7.1
python-dotenv==1.0.1
Pin versions (==, not bare package names). Without pins, a random future pip install in CI or in the Fly builder can pull a newer major version that breaks your app on deploy — exactly the kind of thing you want caught by pytest locally, not discovered in production. Generate pins from a working local environment with:
pip freeze > requirements.txt
then trim it down to what your app actually imports — pip freeze also captures transitive dependencies and anything else installed in that environment, which bloats the file and makes upgrades harder to reason about.
Step 1: Dockerize the App
Fly deploys Docker images, so the Dockerfile is the deployment definition:
FROM python:3.12-slim
# Don't run as root
RUN useradd --create-home appuser
WORKDIR /home/appuser
# Install deps first so this layer is cached between builds
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
USER appuser
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
Two details that matter on Fly:
-
Bind to
0.0.0.0, not127.0.0.1. Fly's proxy connects from outside the container; localhost-only binding is the #1 cause of "deployed but unreachable." - Pick a port and stay consistent. We use 8080 here, and the config below must point at the same number.
Sanity-check locally before touching the cloud:
docker build -t myapi .
docker run -p 8080:8080 myapi
curl localhost:8080/health
Step 2: Create the Fly App and Write fly.yaml
First register the app (this only creates the name — no machines yet):
fly apps create myapi
Then write the config by hand. This is the whole file:
# fly.yaml
app: myapi
primary_region: fra # pick a region close to your users
http_service:
internal_port: 8080 # must match the port uvicorn binds to
force_https: true
auto_start_machines: true
auto_stop_machines: stop # scale to zero when idle
min_machines_running: 0
checks:
- interval: 15s
timeout: 2s
grace_period: 10s
method: GET
path: /health
vm:
- size: shared-cpu-1x # shared CPU is fine for I/O-bound APIs
memory: 512mb
Walking through the blocks:
http_service tells Fly's edge proxy to route public HTTPS traffic to port 8080 inside your machines. force_https redirects any plain-HTTP request; TLS certificates are handled for you. The auto_stop / auto_start / min_machines_running: 0 trio means the app scales to zero when idle and wakes on the next request — essentially free while nobody's using it.
checks is the health check, and it's the most important block in the file. Fly only routes traffic to a machine once GET /health returns 200 — and during a deploy, it won't kill the old machine until the new one passes. That's zero-downtime deployment, given to you for the price of one endpoint.
vm is the machine sizing. Without it you get Fly's default; with it, the specs are version-controlled and enforced on every deploy:
-
size: shared-cpu-1x— shared CPUs are cheap and fine for I/O-bound APIs (which is what most FastAPI services are: the process waits on the network, it doesn't burn CPU). Move toperformance-*sizes only for genuinely CPU-heavy work. -
memory: 512mb— a comfortable floor for Python + uvicorn. 256MB works for tiny apps but leaves little headroom; RAM is usually the first limit you hit. Watchfly logsfor OOM kills once real traffic arrives.
For quick experiments there's also an imperative route (fly scale vm shared-cpu-1x --memory 1024, fly scale count 2), but the config file wins: the next deploy resets machines to whatever fly.yaml says. Treat fly scale as a sandbox and the YAML as the source of truth.
One YAML footgun to know about: env vars and some values must be strings, so if you ever add a numeric-looking value, quote it ("8080", "0.0"). Unquoted, YAML parses it as a number and flyctl may reject it.
Step 3: First Manual Deploy
Automating a deploy you've never run by hand is debugging two systems at once. So first:
fly deploy --config fly.yaml
fly status --config fly.yaml
curl https://myapi.fly.dev/health
Note the --config fly.yaml — without it, flyctl looks for fly.toml and fails. Every fly command that touches the app takes the same flag (or --app myapi).
If your app needs credentials (API keys, a JWT secret), they never go in fly.yaml — the file is committed to git. They go in Fly's encrypted secret store and arrive as environment variables:
fly secrets set JWT_SECRET="..." --app myapi
Rule of thumb: if leaking it would matter, it's a secret; otherwise it can live in the config.
Step 4: The Deploy Token
The GitHub Actions runner needs permission to deploy — but never your personal credentials. Fly issues scoped tokens:
fly tokens create deploy -a myapi -x 720h
-
-a myapiscopes the token to this one app — it cannot touch any other app in your org. -
-x 720hsets an expiry (30 days). Tokens default to 20 years if you omit-x, so always set a shorter one deliberately; a short-lived leak does far less damage than a permanent one.
This prints a token like FlyV1 fm2_lJPECAAAAAAAA... — copy the whole string, including the FlyV1 prefix.
Don't use fly auth token for this. That command returns your personal, all-powerful token — access to every app in your org — and it's deprecated for exactly this reason. Deploy tokens exist so a leaked CI secret has a blast radius of one app, not your whole account.
Store it in GitHub as a repository secret, not anywhere in the code:
- Repo → Settings → Secrets and variables → Actions
- New repository secret
- Name:
FLY_API_TOKEN(must match the name used indeploy.ymlexactly) - Value: the full token string
- Add secret
GitHub encrypts it at rest and masks it in logs; ${{ secrets.FLY_API_TOKEN }} in the workflow pulls it in as an env var at deploy time only. To rotate it later: fly tokens list -a myapi to find the ID, fly tokens revoke <id> to kill it, then repeat the steps above with a fresh token.
Step 5: The GitHub Actions Workflow
The heart of the pipeline — .github/workflows/deploy.yml (this one really is YAML by requirement; GitHub Actions speaks nothing else):
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest httpx ruff
- name: Lint
run: ruff check app/
- name: Run tests
run: pytest -v
deploy:
needs: test # no green tests, no deploy
if: github.ref == 'refs/heads/main' # deploy from main only, never PRs
runs-on: ubuntu-latest
concurrency: deploy-group # never two deploys at once
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
run: flyctl deploy --remote-only --config fly.yaml
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Reading it top to bottom:
-
Triggers. Every push to
mainand every PR targetingmainruns the workflow. PRs get tests only; merges tomainget tests and deploy. So every change is verified before merge, and every merge ships automatically. -
needs: testmakes deployment conditional on green tests — the whole point of CI/CD. A broken build physically cannot reach production. -
if: github.ref == 'refs/heads/main'double-guards the deploy job so PR runs never deploy. -
concurrency: deploy-groupqueues a second push behind an in-flight deploy instead of racing it. -
--remote-onlybuilds the Docker image on Fly's builders rather than inside the Actions runner — faster, and no Docker setup needed in CI. -
--config fly.yaml— same flag as your manual deploy. The pipeline runs exactly the command you already trust.
A matching test so the gate has something real to check:
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
Step 6: Push and Watch It Go
git add .
git commit -m "Add CI/CD pipeline"
git push origin main
Open the repo's Actions tab: checkout → lint → tests → deploy. When the last step goes green, https://myapi.fly.dev/health is serving the new code. From now on, your entire release process is: merge to main.
Day-2 Operations
Commands you'll actually use once this runs:
fly logs --app myapi # live application logs
fly status --app myapi # machine states, deployed version
fly releases --app myapi # deployment history
fly deploy --image <ref> --config fly.yaml # roll back to a previous image
fly ssh console --app myapi # shell into a running machine
When a deploy goes bad, the health check usually saves you: the new machine never passes /health, traffic stays on the old one, and the failure shows up loudly in the Actions log instead of silently in production.
Where This Grows Next
The YAML habit pays off as soon as the app stops being alone:
-
Staging + production: duplicate the file as
fly.stg.yamlandfly.prod.yaml, point each at its own Fly app with its own deploy token, and add a second deploy job gated on adevelopbranch. Same skeleton, one--configflag apart. -
Background workers: a
processes:block lets one image run bothuvicornand a task worker on separately sized machines. - Databases: attach Postgres or Redis as separate Fly apps on the private network, unreachable from the internet.
-
Hardening the pipeline: add
pip-auditortrivyto the test job, and a coverage threshold topytest.
But the skeleton never changes: a Dockerfile, a fly.yaml, a deploy.yml, and one scoped token. That's the entire distance between "runs on my laptop" and "ships on every merge."
Top comments (0)