A good CI/CD pipeline is one of the highest-leverage things you can set up on a project. It catches bugs you’d otherwise see in production, automates tedious work, and gives you a deployment story you don’t have to think about. GitHub Actions makes building one genuinely easy — and it’s free for public repos.

This post is the practical, copy-pasteable guide. We’ll build a real pipeline for a Python app: lint, type check, run tests against a real Postgres, build a Docker image, push to a registry, and deploy. Plus the patterns that make GitHub Actions fast and maintainable.

What we’re building

push → ┌─────────┐  ┌──────────┐  ┌────────────┐  ┌────────┐
       │  lint   │  │ type-chk │  │   tests    │  │ build  │  → deploy
       └─────────┘  └──────────┘  └────────────┘  └────────┘
       (parallel)                  (with Postgres)  (Docker)

All workflow files live in .github/workflows/. Triggered on push, pull request, and tag.

A baseline ci.yml

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
      - run: uv sync --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .

  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
      - run: uv sync --dev
      - run: uv run mypy app

  tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgresql+asyncpg://testuser:testpass@localhost:5432/testdb
      SECRET_KEY: ci-secret
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
      - run: uv sync --dev
      - run: uv run pytest -v --cov=app --cov-report=xml
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

A few choices in this file are worth calling out:

concurrency cancels stale runs

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

Push two commits to the same branch in quick succession; the older run is cancelled. Saves CI minutes and gets you faster signal.

Service containers for real DB tests

The services: block spins up a Postgres container that exists only for this job. The healthcheck makes the job wait until Postgres is actually ready. This is hugely better than mocking the DB — see Testing FastAPI Apps for why.

setup-uv with caching

uv has its own cache that’s much faster than pip’s. With enable-cache: true, GitHub Actions caches the resolved dependencies between runs.

Codecov upload

Optional but cheap. Tracks coverage trends and shows them on PRs.

Matrix builds: test multiple Python versions

tests:
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      python: ["3.11", "3.12", "3.13"]
  steps:
    - uses: actions/checkout@v4
    - uses: astral-sh/setup-uv@v3
    - run: uv sync --python ${{ matrix.python }} --dev
    - run: uv run pytest

fail-fast: false keeps all matrix entries running even if one fails. You get full visibility instead of one red entry hiding others.

Building and pushing a Docker image

# .github/workflows/release.yml
name: release

on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write    # to push to ghcr.io

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What’s happening:

  1. docker/setup-qemu + buildx — enables multi-arch builds (linux/amd64 + linux/arm64). Big wins if any of your nodes are ARM (Graviton, Apple Silicon, Raspberry Pi).
  2. metadata-action generates a sensible set of tags from the git ref. Tag v1.2.3 → image tags 1.2.3, 1.2, latest (and a SHA-based tag).
  3. cache-from/to: type=gha uses GitHub’s own cache for layer caching — usually the fastest option for Actions.

For Dockerfile best practices, see Docker for Python Developers .

Deploying

The “deploy” step varies wildly by where you ship. Three common shapes:

Deploy to a PaaS (Fly.io, Render, Railway)

deploy:
  needs: docker
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: superfly/flyctl-actions/setup-flyctl@master
    - run: flyctl deploy --remote-only
      env:
        FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

Deploy to Kubernetes (GitOps)

The “right” pattern: CI builds and pushes the image, then commits the new tag to a separate “deploy” repo. Argo CD or Flux running in the cluster watches that repo and applies the change. Your CI never has cluster credentials — much safer.

For simpler setups:

deploy:
  needs: docker
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: azure/setup-kubectl@v4
    - run: |
        echo "${{ secrets.KUBECONFIG }}" | base64 -d > kubeconfig
        export KUBECONFIG=kubeconfig
        kubectl set image deployment/api api=ghcr.io/me/api:${{ github.sha }}
        kubectl rollout status deployment/api --timeout=2m

Deploy to a VPS over SSH

deploy:
  needs: docker
  runs-on: ubuntu-latest
  steps:
    - uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.SSH_HOST }}
        username: deploy
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        script: |
          cd /opt/myapp
          docker pull ghcr.io/me/api:${{ github.sha }}
          docker compose up -d

Secrets and environments

Use GitHub Environments for per-environment secrets and approval rules:

deploy-prod:
  environment: production    # ← gates this job behind environment rules
  needs: docker
  steps:
    - run: deploy.sh
      env:
        API_KEY: ${{ secrets.PROD_API_KEY }}

In the repo settings, configure the production environment:

  • Required reviewers (manual approval before deploy).
  • Branch restriction (main only).
  • Per-environment secrets (different from staging).

This is one of the most underused features of GitHub Actions and one of the most valuable.

Caching that pays off

Beyond setup-uv’s built-in cache, you can cache anything:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/pip
      .venv
    key: ${{ runner.os }}-py${{ matrix.python }}-${{ hashFiles('uv.lock') }}
    restore-keys: |
      ${{ runner.os }}-py${{ matrix.python }}-

Rule of thumb: cache anything that takes >30 seconds to compute and is keyed by a stable input. The lockfile hash is usually the right key.

Reusable workflows: don’t repeat yourself

Got 5 services with similar workflows? Define a reusable workflow:

# .github/workflows/_python-ci.yml
on:
  workflow_call:
    inputs:
      python-version:
        type: string
        default: "3.13"

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --dev
      - run: uv run ruff check . && uv run pytest

Call it from each service’s repo:

# .github/workflows/ci.yml
on: [push, pull_request]
jobs:
  call-ci:
    uses: AlzyWelzy/.github/.github/workflows/_python-ci.yml@main
    with:
      python-version: "3.13"

GitHub even has a .github repo convention — workflows in <org>/.github/.github/workflows/ are shared across the org.

Speed tips

  • Cancel stale runs with concurrency (above).
  • Run jobs in parallel when possible — keep dependencies between jobs minimal.
  • Cache aggressively — uv, pip, Docker layers, Node modules, anything.
  • Use paths-ignore so doc changes don’t trigger full CI:
    on:
      pull_request:
        paths-ignore: ["**.md", "docs/**"]
    
  • Use larger runners for slow builds — GitHub offers paid 4-, 8-, 16-core runners.
  • Splitting tests across runners (matrix + pytest-split or pytest-xdist) for very large test suites.

Security hygiene

  • Don’t commit secrets. GitHub scans for known patterns; rotate immediately if it warns.
  • permissions: block — grant only what each job needs. The default is too permissive.
  • Pin actions to a SHA for security-sensitive workflows: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1.
  • Don’t run untrusted PRs with privileged secretspull_request_target is dangerous; understand it before using.
  • Audit third-party actions. Stick to verified publishers when possible.

A complete file you can copy

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { enable-cache: true }
      - run: uv sync --dev
      - run: uv run ruff check .
      - run: uv run ruff format --check .

  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { enable-cache: true }
      - run: uv sync --dev
      - run: uv run mypy app

  tests:
    runs-on: ubuntu-latest
    needs: [lint]
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    env:
      DATABASE_URL: postgresql+asyncpg://testuser:testpass@localhost:5432/testdb
      SECRET_KEY: ci-secret
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { enable-cache: true }
      - run: uv sync --dev
      - run: uv run pytest -v --cov=app

Drop this into .github/workflows/ci.yml, push, and you have a real pipeline.

Conclusion

A good CI/CD pipeline is one of those investments that pays back forever. With GitHub Actions, the marginal cost of “actually run our tests on every PR” is essentially zero. Set it up early — before the first time you ship a regression you’d have caught.

For the deployment side of the picture, see Deploying Django to Production and Kubernetes for App Developers .

Happy automating!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .