flowchart TB
subgraph ci["CI: on every pull request"]
direction LR
L["Lint and types"] --> T["Tests"] --> MG["Migration check"] --> B["Build"]
end
subgraph cd["CD: after the merge"]
direction LR
S["Deploy to staging"] --> SM["Smoke tests"] --> P["Deploy to production"]
end
ci -- "all green: merge" --> cd
style ci fill:transparent,stroke:#adb5bd
style cd fill:transparent,stroke:#adb5bd
9 CI/CD and environments
CI runs on every PR, and the deploy to production goes out automatically from main, always passing through staging first. The goal is for releasing to be boring: a repeatable process with no steps kept in someone’s memory.
- CI (continuous integration): every change is checked automatically before it goes in.
- CD (continuous delivery): whatever lands in
mainreaches production through an automated process.
9.1 The pipeline
9.2 What CI checks
An example for Farol (Django on the backend, React with Vite and TypeScript on the frontend):
| Step | Backend (api/) |
Frontend (web/) |
|---|---|---|
| Formatting and lint | ruff check and ruff format --check |
eslint and prettier --check |
| Types | mypy (optional at first) |
tsc --noEmit |
| Tests | python manage.py test or pytest |
vitest run |
| Migrations | python manage.py makemigrations --check --dry-run |
— |
| Build | — | vite build |
| Security | pip-audit |
npm audit --omit=dev |
Start small: lint, tests, and the migration check. Add the rest gradually. A CI that fails over trivia ends up ignored in practice.
A minimal GitHub Actions workflow for the backend:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt ruff
- run: ruff check .
- run: python manage.py makemigrations --check --dry-run
- run: python manage.py test9.3 Environments
| Environment | What it’s for | Data |
|---|---|---|
| Local | Development | Fictional (seed) |
| Staging | Test the deploy and validate before production | Fictional, never customers’ |
| Production | Customers | Real, restricted access |
Real data never leaves production. No copying the database to debug on a laptop. To reproduce a problem, create equivalent fictional data. In a financial product, this rule has no exceptions.
9.4 Deploy and rollback
- The deploy is a workflow, not a command typed by hand.
- Every version in production has an identifiable tag or SHA.
- Rollback is written in a runbook and has been tested at least once.
- The production deploy can require manual approval using GitHub Environments, with required reviewers.
9.5 Secrets in CI
Secrets get into CI through GitHub Actions Secrets or are read from a password manager through an official integration. Never write a secret in the workflow YAML. Details in Chapter 14.
9.6 Delivery metrics
The DORA research identified four metrics that set high-performing teams apart:
| Metric | Question |
|---|---|
| Deployment frequency | How often do we release? |
| Lead time for changes | How long from commit to production? |
| Change failure rate | How many deploys cause a problem? |
| Time to restore | How long to get back to normal after a failure? |
A small team doesn’t need a dashboard: at the iteration review, note how many deploys there were and how many caused problems.
This book’s workflow is a complete pipeline in miniature: on every PR, it renders the book and checks the links; on main, it publishes to GitHub Pages. See the runs in the Actions tab.
Create your project’s minimal CI with a single step: the tests. Open a PR with a deliberately broken test and watch CI block the merge.