šÆ What You Will Learn
"Works on my machine" is where bugs are born. A CI pipeline runs your lint and tests on every push ā on a clean machine, against a real database ā before anything merges. We'll build that with GitHub Actions, then have it build and ship a Docker image automatically.
- GitHub Actions basics: workflows, jobs, steps, triggers
- A CI job that lints (
ruff) and tests (pytest) on every push/PR - Running tests against a real Postgres service container
- Caching pip dependencies for fast runs
- Building & pushing a Docker image to GHCR on merge to
main - A CI status badge and branch protection
Prerequisites: a FastAPI app with tests (fastapi-unit-testing, fastapi-integration-testing) and a Dockerfile (fastapi-docker). This post wires them into an automated pipeline.
fastapi-ci/ āāā .github/ ā āāā workflows/ ā āāā ci.yml # the pipeline (lint ā test ā build) āāā app/ # your FastAPI app āāā tests/ # pytest suite āāā Dockerfile āāā requirements.txt
The Problem: "Works on My Machine" (See It First)
Without CI, the only thing standing between a bug and main is whether the author remembered to run the tests locally ā with their local Python, their local env vars, their local database.
Dev A: forgets to run pytest, pushes to main āāāŗ broken tests merged Dev B: pulls main, nothing runs āāāŗ "why is prod 500-ing?" Dev C: "but it works on my machine..." āāāŗ different Python version No gate. No clean-room check. Bugs merge because a human forgot a step.
GitHub Actions 101
A pipeline is just a YAML file in .github/workflows/. Four concepts and you can read any workflow:
| Concept | What it is |
|---|---|
| Workflow | One .yml file. Triggered by events (push, PR, schedule). |
| Job | A set of steps that run together on one runner (VM). Jobs can run in parallel. |
| Step | A single task: run a shell command, or uses: a prebuilt action. |
| Runner | The machine your job runs on (e.g. ubuntu-latest). |
needs: when one job depends on another's success.Your First Workflow: Lint + Test
Create .github/workflows/ci.yml. This runs on every push to main and every pull request: check out the code, set up Python (with pip caching baked in), install, lint, and test.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip # built-in pip dependency caching
- run: pip install -r requirements.txt
- name: Lint
run: ruff check .
- name: Test
run: pytest -vcache: pip on setup-python is the easy win: it caches your pip downloads keyed by your requirements file, so repeat runs skip re-downloading. No separate cache action needed for the common case.Testing Against a Real Postgres (Service Container)
Your integration tests need a database. GitHub Actions can spin up a service container ā a Postgres that lives for the duration of the job. The key detail everyone misses: a health-check so your tests don't start before Postgres is ready.
jobs:
test:
runs-on: ubuntu-latest
services:
db:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
# wait until Postgres is actually accepting connections
options: >-
--health-cmd "pg_isready -U app"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
# your app/tests read the DB URL from the environment
DATABASE_URL: postgresql+psycopg://app:app@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: pytest -vconnection refused. The --health-cmd/--health-retries options make Actions wait until pg_isready passes before running your steps.DATABASE_URL (via pydantic-settings). The same env-driven config that runs locally now runs in CI against the service container ā exactly the parity fastapi-integration-testing is built on.Caching Dependencies (Faster Runs)
cache: pip covers most projects. If you need finer control (e.g. caching a custom directory or a tool's cache), use actions/cache directly with a key derived from your lockfile:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
# new key when requirements change -> fresh install; else cache hit
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-key includes a hash of requirements.txt, so the cache invalidates automatically when dependencies change. restore-keys lets a partial (older) cache be reused as a fallback. Faster CI = faster feedback = you actually wait for it before merging.Build & Push a Docker Image to GHCR
Green tests are step one. Now the CD half: on merge to main, build your Docker image and push it to the GitHub Container Registry (GHCR). This job needs: test, so it only runs if tests passed, and is gated to the main branch.
docker:
needs: test # only if tests pass
if: github.ref == 'refs/heads/main' # only on main, not PRs
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # required to push to GHCR
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # auto-provided, no setup
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}permissions: packages: write or the push is denied; (2) secrets.GITHUB_TOKEN is provided automatically by Actions ā you don't create it. Tagging with github.sha as well as latest gives every image an immutable, traceable version.Adding a Deploy Step (CD)
The last mile depends on your host. Most platforms expose a deploy hook or CLI ā call it after the image is pushed, feeding any credentials in from encrypted repository secrets (never hardcoded).
- name: Trigger deploy
run: curl -fsS -X POST "$DEPLOY_HOOK"
env:
DEPLOY_HOOK: ${{ secrets.DEPLOY_HOOK }} # set in repo Settings ā Secretssecrets.NAME. They're encrypted, masked in logs, and never exposed to workflows triggered by forked PRs ā see fastapi-deployment for host-specific deploy details.Try It (Push & Watch)
git add .github/workflows/ci.yml
git commit -m "ci: lint, test, and build"
git push origin mainGitHub ā your repo ā Actions tab: CI #12 ā main āāā test ā (ruff clean, 6 passed in 0.9s, Postgres service healthy) āāā docker ā (pushed ghcr.io/you/app:latest + :a1b2c3d) green check ā appears next to your commit ā safe to merge
main ā builds and publishes a Docker image, all automatically. No more "works on my machine."Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| No health check on the DB service | Flaky connection refused in tests | Add --health-cmd pg_isready + retries |
Missing permissions: packages: write | GHCR push denied (403) | Grant the permission in the job |
| Hardcoding secrets in the YAML | Credentials leak in a public repo | Use secrets.* repository secrets |
Unpinned actions (@main) | Builds break when an action changes | Pin versions (@v4) |
| Docker job runs on every PR | Publishes images from unmerged branches | if: github.ref == 'refs/heads/main' |
| No branch protection | Red builds still get merged | Require the CI check to pass before merge |
Production Notes
- Turn on branch protection. Settings ā Branches ā require the CI check to pass before merging to
main. A green pipeline only helps if red actually blocks the merge. - Run migrations in CI. Apply your Alembic migrations against the service DB before
pytest(fastapi-alembic-migrations) so you test the real schema, not just your models. - Matrix-test when it matters. A
strategy.matrixover Python versions catches version-specific breakage ā worth it for libraries, overkill for a single-deploy app. - Deploy on tags for releases. Trigger CD on version tags (
on: push: tags: ['v*']) so you ship deliberate releases, not every commit tomain.
What's Next
fastapi-integration-testingā the tests your CI runs against the Postgres servicefastapi-dockerā theDockerfilethe build job publishesfastapi-deploymentā where the pushed image actually gets deployedfastapi-alembic-migrationsā run migrations as a CI step for real schema parity
Recap: a workflow in .github/workflows/ lints and tests every push on a clean runner ā against a real Postgres service container ā and, on main, builds and pushes a Docker image to GHCR. Pin your actions, keep secrets in repository secrets, and require the check with branch protection. Ship with confidence, not crossed fingers.
fastapi-ci/ # ā finished āāā .github/ ā āāā workflows/ ā āāā ci.yml # test (ruff + pytest + Postgres) ā docker (GHCR) āāā app/ āāā tests/ āāā Dockerfile āāā requirements.txt