DEV Community

Cover image for Zero to Production: FastAPI on Fly.io and GitHub Actions
Dedar Alam
Dedar Alam

Posted on

Zero to Production: FastAPI on Fly.io and GitHub Actions

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
Enter fullscreen mode Exit fullscreen mode

Three moving parts:

  1. Your FastAPI app, packaged in a Docker image so it runs identically everywhere.
  2. Fly.io, which takes that image and runs it on small VMs called Machines — handling TLS, routing, health checks, and restarts.
  3. 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 flyctl CLI (curl -L https://fly.io/install.sh | sh)
  • A Fly.io account (fly auth signup or fly auth login)

Project layout:

myapi/
├── app/
│   ├── __init__.py
│   └── main.py
├── tests/
│   └── test_main.py
├── requirements.txt
├── Dockerfile
├── fly.yaml
└── .github/
    └── workflows/
        └── deploy.yml
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Two details that matter on Fly:

  • Bind to 0.0.0.0, not 127.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 to performance-* 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. Watch fly logs for 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • -a myapi scopes the token to this one app — it cannot touch any other app in your org.
  • -x 720h sets 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:

  1. Repo → Settings → Secrets and variables → Actions
  2. New repository secret
  3. Name: FLY_API_TOKEN (must match the name used in deploy.yml exactly)
  4. Value: the full token string
  5. 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 }}
Enter fullscreen mode Exit fullscreen mode

Reading it top to bottom:

  • Triggers. Every push to main and every PR targeting main runs the workflow. PRs get tests only; merges to main get tests and deploy. So every change is verified before merge, and every merge ships automatically.
  • needs: test makes 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-group queues a second push behind an in-flight deploy instead of racing it.
  • --remote-only builds 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"}
Enter fullscreen mode Exit fullscreen mode

Step 6: Push and Watch It Go

git add .
git commit -m "Add CI/CD pipeline"
git push origin main
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.yaml and fly.prod.yaml, point each at its own Fly app with its own deploy token, and add a second deploy job gated on a develop branch. Same skeleton, one --config flag apart.
  • Background workers: a processes: block lets one image run both uvicorn and 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-audit or trivy to the test job, and a coverage threshold to pytest.

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)