FastAPI

FastAPI CI/CD with GitHub Actions (Lint, Test & Ship Automatically)

Thirdy Gayares
14 min read

šŸŽÆ 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.
āš ļø
Manual testing doesn't scale and humans forget. CI removes the "did you remember?" question entirely: every push runs the same checks on a fresh machine. If it's red, it doesn't merge. That's the whole value.

GitHub Actions 101

A pipeline is just a YAML file in .github/workflows/. Four concepts and you can read any workflow:

ConceptWhat it is
WorkflowOne .yml file. Triggered by events (push, PR, schedule).
JobA set of steps that run together on one runner (VM). Jobs can run in parallel.
StepA single task: run a shell command, or uses: a prebuilt action.
RunnerThe machine your job runs on (e.g. ubuntu-latest).
šŸ’”
Ang importante dito: steps in a job share the same runner and filesystem (so a checkout in step 1 is available in step 3). Separate jobs get fresh runners — use 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.

.github/workflows/ci.yml
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 -v
āœ…
That's a real CI pipeline. Commit it, push, and open the Actions tab — you'll see the run execute your lint and tests on a clean Ubuntu VM. Every PR now gets the same check automatically.
šŸ’”
cache: 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.

.github/workflows/ci.yml (test job with Postgres)
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 -v
āš ļø
Without the health check, tests flake. The runner starts the Postgres container and your steps in parallel — hit the DB too early and you get connection refused. The --health-cmd/--health-retries options make Actions wait until pg_isready passes before running your steps.
šŸ’”
Point your test config at 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:

.github/workflows/ci.yml (manual cache, optional)
      - 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-
šŸ’”
The 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.

.github/workflows/ci.yml (add a docker job)
  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 }}
āš ļø
Two gotchas: (1) you must set 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).

.github/workflows/ci.yml (a deploy step)
      - name: Trigger deploy
        run: curl -fsS -X POST "$DEPLOY_HOOK"
        env:
          DEPLOY_HOOK: ${{ secrets.DEPLOY_HOOK }}   # set in repo Settings → Secrets
šŸ’”
Add repository secrets under Settings → Secrets and variables → Actions. Reference them as secrets.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)

1Commit the workflow and push
run.sh
git add .github/workflows/ci.yml
git commit -m "ci: lint, test, and build"
git push origin main
2Watch it run in the Actions tab
GitHub → 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
3Add a status badge to your README
README.md
![CI](https://github.com/USERNAME/REPO/actions/workflows/ci.yml/badge.svg)
āœ…
You did it: every push now lints, tests against a real Postgres, and — on main — builds and publishes a Docker image, all automatically. No more "works on my machine."

Common Mistakes (and Fixes)

MistakeSymptomFix
No health check on the DB serviceFlaky connection refused in testsAdd --health-cmd pg_isready + retries
Missing permissions: packages: writeGHCR push denied (403)Grant the permission in the job
Hardcoding secrets in the YAMLCredentials leak in a public repoUse secrets.* repository secrets
Unpinned actions (@main)Builds break when an action changesPin versions (@v4)
Docker job runs on every PRPublishes images from unmerged branchesif: github.ref == 'refs/heads/main'
No branch protectionRed builds still get mergedRequire 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.matrix over 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 to main.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-integration-testing — the tests your CI runs against the Postgres service
  • fastapi-docker — the Dockerfile the build job publishes
  • fastapi-deployment — where the pushed image actually gets deployed
  • fastapi-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

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.