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:
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).metadata-actiongenerates a sensible set of tags from the git ref. Tagv1.2.3→ image tags1.2.3,1.2,latest(and a SHA-based tag).cache-from/to: type=ghauses 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 (
mainonly). - 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-ignoreso 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 secrets —
pull_request_targetis 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 .