Part 1 covered building a FastAPI application with a Dockerfile and getting it running locally. Here you take that same application and ship it to Fly.io, then wire up a three-stage GitHub Actions pipeline that tests, builds, and deploys on every push to main. By the end, you have a production URL, a health check Fly.io polls continuously, and a CI/CD pipeline that will not deploy broken code.
Prerequisites
You need the flyctl CLI installed and authenticated (fly auth login), a GitHub repository containing the Part 1 application, and a Fly.io account. The application structure from Part 1 should have src/main.py, a Dockerfile, and a requirements.txt at the repository root.
Adding the health check endpoint
Fly.io needs a route it can poll to determine whether your machine is healthy. Without one, the platform has no way to distinguish a machine that is starting from one that has crashed. Add the following to src/main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health_check():
return {"status": "ok"}
@app.get("/")
async def root():
return {"message": "Hello from Fly.io"}
The /health endpoint returns a JSON body with HTTP 200. That is all Fly.io requires. Keep the route lightweight — no database calls, no external HTTP requests. If those dependencies are down, you still want the machine to report healthy so the platform does not cycle it unnecessarily.
Initialising the Fly.io application
Run fly launch from the repository root. The --no-deploy flag tells flyctl to generate configuration without immediately pushing anything to the platform:
fly launch --no-deploy
The interactive prompt will ask you several questions. Give the application a name or accept the generated one. When asked whether to set up a Postgres database, decline. When asked whether to add an Upstash Redis database, decline that too. When prompted for a region, type lhr to select London, or confirm it if the CLI has already detected it. The prompt will detect your Dockerfile and select it as the build strategy automatically.
fly launch writes a fly.toml file. The generated file will not match what you need, so replace its contents entirely with the following:
app = "your-app-name"
primary_region = "lhr"
[build]
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
grace_period = "5s"
interval = "10s"
method = "GET"
path = "/health"
timeout = "2s"
Replace your-app-name with the name you provided during fly launch. Every field here is deliberate. internal_port = 8000 must match the port your Uvicorn process listens on inside the container — if Part 1 used a different port, change it here. force_https = true redirects all plain HTTP traffic to HTTPS; there is no reason to leave this off. auto_stop_machines = "stop" is a string value, not a boolean — Fly.io accepts "stop" or "suspend" here, and the distinction matters: "stop" shuts the machine down completely, reducing cost to zero when idle, whereas "suspend" keeps memory warm at a small cost. min_machines_running = 0 allows the count to drop to zero entirely, which is what makes the free tier viable for low-traffic applications.
The [[http_service.checks]] block configures a TCP health check that Fly.io runs every ten seconds after a five-second grace period on startup. The timeout = "2s" means a response that takes longer than two seconds counts as a failure. Three consecutive failures cause the platform to mark the machine as unhealthy and restart it.
Creating a deploy token
Never use your personal Fly.io authentication token in CI. Instead, generate a scoped deploy token with a finite lifetime:
fly tokens create deploy -x 999999h
The -x flag sets the token expiry. Here it is set to 999999 hours, which is approximately 114 years — effectively long-lived. That is a deliberate trade-off: a shorter expiry is more secure because a leaked token becomes useless after expiry, but it requires you to rotate the secret in GitHub manually before it expires, or your pipeline breaks silently. If this application processes sensitive data or the repository is public, use a shorter expiry such as 8760h (one year) and set a calendar reminder to rotate it. For a private hobby project, the long-lived token is an acceptable convenience.
Copy the token output. In your GitHub repository, go to Settings, then Secrets and variables, then Actions, and create a new repository secret named FLY_API_TOKEN. Paste the token as the value.
The GitHub Actions workflow
Create the file .github/workflows/deploy.yml in your repository:
name: Test, Build, Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
push: false
deploy:
needs: [test, build]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
concurrency:
group: deploy-group
steps:
- uses: actions/checkout@v4
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
The workflow has three jobs. The test job runs on every push and every pull request to main. It checks out the code, installs Python 3.11, installs your dependencies, and runs pytest. If any test fails, the workflow stops here — neither build nor deploy will execute because they declare needs: test.
The build job validates that Docker can produce an image from your Dockerfile. It uses docker/setup-buildx-action@v3 to configure BuildKit and docker/build-push-action@v5 with push: false, meaning it builds the image but does not push it to any registry. The point of this job is to catch Dockerfile errors in CI before handing off to Fly.io’s remote builder. Fly.io does its own build during deployment, so you are not duplicating the pushed artefact — you are just validating the build succeeds.
The deploy job runs only on pushes to main, not on pull requests. The if: github.ref == 'refs/heads/main' condition enforces this. The concurrency block with group: deploy-group ensures that if two pushes land in quick succession, the second deployment waits for the first to finish rather than running in parallel. Parallel deployments to the same Fly.io application cause race conditions that are difficult to diagnose. flyctl deploy --remote-only hands the build off to Fly.io’s infrastructure rather than building locally in the runner. The FLY_API_TOKEN environment variable is populated from the secret you created earlier. Without it, flyctl cannot authenticate and the step fails immediately.
First manual deployment
Before the pipeline can deploy, the application needs to exist on Fly.io. Push the initial deployment manually from your local machine:
fly deploy
This builds the image using your Dockerfile, pushes it to Fly.io’s registry, and starts a machine. Watch the output — it will show the health check URL being polled after startup. Once the machine passes the health check, flyctl reports the application URL. Hit https://your-app-name.fly.dev/health in your browser and confirm you get {"status": "ok"}.
From this point forward, every push to main triggers the pipeline. The test job runs first, the build job validates the Dockerfile in parallel once tests pass, and the deploy job fires only when both succeed.
What the pipeline does not yet do
Part 3 extends the test job with coverage reporting via pytest-cov, linting via ruff, and dependency vulnerability scanning via trivy or pip-audit. These additions give you a quality gate that rejects code with insufficient test coverage, style violations, or known CVEs in dependencies — all before any code reaches the platform. The pipeline structure you have built here is designed for exactly that kind of incremental extension: add steps to the test job, and they automatically block both build and deploy if they fail.