24.5. CI/CD Pipelines
Table of Contents
Understanding CI/CD Pipelines
A CI/CD pipeline is the automated path that your code follows from a Git commit to running in a real environment. Instead of doing steps manually, you describe them once, and a pipeline runs them in the same way every time.
This chapter focuses on what a CI/CD pipeline is, what typical stages look like, and how it fits into backend development. The next chapters will show concrete examples with GitHub Actions and GitLab CI/CD.
What Is a CI/CD Pipeline?
A CI/CD pipeline is a series of automated steps that run whenever code changes. These steps usually include:
- Fetching the latest code
- Installing dependencies and tools
- Running tests and quality checks
- Building artifacts, for example Docker images
- Deploying to an environment, such as staging or production
You define these steps in a configuration file, usually in YAML, stored in the same repository as your code.
Key idea: A CI/CD pipeline is code that automates how you build, test, and deploy your application. If you cannot run it with one command or one button, it is not a proper pipeline.
Basic Structure of a Pipeline
Most pipeline systems share the same core ideas:
- Jobs: Individual units of work, for example "run tests"
- Stages: Ordered groups of jobs, for example "test" stage, then "build" stage
- Triggers: Conditions when the pipeline should run, for example "on every push to main"
- Artifacts: Files produced by one job and used by another, for example test reports
- Environments: Places where you deploy, such as "staging" or "production"
A typical minimal pipeline has at least these stages:
- Checkout / setup
- Test
- Build
- Deploy
The exact names differ between tools, but the idea is the same.
Example: Conceptual Pipeline Flow
| Stage | Example job | Purpose |
|---|---|---|
| setup | Checkout repo, install Python | Prepare environment |
| test | Run unit and API tests | Prove code still works |
| build | Build Docker image | Create deployable package |
| deploy | Deploy to staging or prod | Release new version of application |
For a backend project, your pipeline will usually at least run Python tests and build Docker images.
Triggers and Branch-Based Pipelines
You rarely want to run the same actions for every branch. For example, you might:
- Run tests for all branches and pull requests.
- Build and deploy to staging when you push to
develop. - Deploy to production only when you push a tag or merge to
main.
Typical triggers include:
- On push: Run when someone pushes commits.
- On pull request / merge request: Run before merging to validate changes.
- On tag: Run for versioned releases, for example
v1.2.0. - On schedule: Run nightly jobs, for example full test suite, security scans.
In your config file you usually describe something like:
- "Run test jobs on every push"
- "Run deploy jobs only on main branch and only when the tests have passed"
This is how pipelines enforce safe workflows.
Common Pipeline Stages for Backend Projects
1. Checkout and Setup Stage
This stage prepares everything other stages need.
Typical steps:
- Check out the repository from Git
- Set Python version, for example 3.11
- Install dependencies with
pip install -r requirements.txt - Set environment variables, such as
ENV=cior database URLs
Example (conceptual, tool-agnostic):
- stage: setup
jobs:
- name: setup-python
steps:
- checkout-code
- install-python: "3.11"
- pip-install: "requirements.txt"The exact syntax differs between tools, but the meaning is the same: get code, install Python, install packages.
2. Test Stage
This is the core of Continuous Integration. The goal is to reject broken changes automatically.
Typical jobs:
- Run unit tests with
pytest - Run integration tests, sometimes with temporary databases and services
- Collect coverage
- Optionally, run static checks like
flake8ormypy
Many teams separate "tests" into multiple jobs for speed and clarity.
Example layout:
| Job | Command example | Purpose |
|---|---|---|
| unit-tests | pytest tests/unit | Fast feedback |
| integration | pytest tests/integration | DB, Redis, external systems |
| style-check | flake8 app | Code style |
| type-check | mypy app | Type safety |
A pipeline can run these jobs in parallel within the same stage. The stage is successful only if all jobs succeed.
Rule: A pipeline must fail fast when any test fails. Do not allow broken tests to "pass" or be ignored. If tests are flaky, fix them, do not just rerun manually.
3. Build Stage
Once your code passes tests, you create something that can be deployed. For backend applications this is often:
- A Docker image that contains your code and dependencies
- A Python package, for example
.whlor.tar.gz, if you are publishing a library
In Docker-based backend projects, a common pattern is:
- Build a Docker image using your
Dockerfile. - Tag it with the commit SHA and/or a version.
- Push it to a container registry, for example GitHub Container Registry or Docker Hub.
Conceptual example:
- stage: build
needs: [test] # only run if tests passed
jobs:
- name: build-and-push-image
steps:
- docker-build: "my-api:${COMMIT_SHA}"
- docker-push: "registry.example.com/my-api:${COMMIT_SHA}"The important part is the dependency: the build stage must not run if tests fail.
4. Deploy Stage
The deploy stage uses the artifact from the build stage to update a real environment.
Common patterns:
- Deploy to a staging environment on every push to a development branch.
- Deploy to production only on approved merges or tags.
For Docker-based deployments, a deploy job might:
- Pull the new image from the registry.
- Run
docker-compose pull && docker-compose up -d. - Run database migrations, for example
alembic upgrade head.
Pipeline systems usually allow:
- Manual approvals for deploy jobs, so someone must click a button before deploying to production.
- Environment protection, so only specific people can deploy to production.
Rule: Never deploy directly from a developer machine to production. Always deploy from a CI/CD pipeline using a versioned artifact that has passed tests.
Parallelism and Dependencies
Pipelines are often represented as graphs. Jobs can run:
- In parallel when they are independent, for example "lint" and "test".
- Sequentially when one depends on the output of another, for example "build" after "test".
For example:
lintandunit-testsrun in parallel.integration-testsdepend onunit-tests.builddepends on all tests.deploydepends onbuild.
Visual idea:
- Stage
test lintunit-testsintegration-tests(needsunit-tests)- Stage
build build-docker(needslint,unit-tests,integration-tests)- Stage
deploy deploy-staging(needsbuild-docker)
This structure gives fast feedback, since simple checks finish quickly and can fail the pipeline before slower jobs even start.
Artifacts and Caching
Pipelines often run on clean, temporary machines. They start from zero each time. Two concepts help avoid repeated work:
Artifacts
Artifacts are files produced by one job and passed to others. Examples:
- Coverage reports
- HTML test reports
- Compiled assets
- Build outputs
For example, an integration-tests job might upload a test report as an artifact, then a later "collect-reports" job could combine them or publish them as a web page.
Caches
Caches speed up repeated work, such as:
- Python dependencies in a
venvor~/.cache/pip - Node modules for frontend builds in full-stack repos
You usually configure the cache key using the requirements.txt checksum and possibly the Python version. If the dependencies file has not changed, the pipeline can reuse cached dependencies.
Rule: Use artifacts for results that must be exact and tracked, like build outputs, and caches only for things that can be safely regenerated, like dependencies.
Environments and Promotion
A key practice in CI/CD is promoting the same artifact through multiple environments.
Common environments:
- Dev: Where developers or feature branches deploy frequently for quick checks.
- Staging: Production-like environment used for final testing.
- Production: Real users.
Instead of rebuilding for each environment, you:
- Build one Docker image in the pipeline.
- Test it.
- Deploy the same image to staging.
- If staging is good, deploy exactly the same image to production.
This avoids "works in staging but not in prod" due to different builds or dependency versions.
Typical pipeline design:
- On push to
develop: run tests, build image, deploy to staging. - On tag
vX.Y.Z: re-use the existing image or build once, then deploy to production after manual approval.
Manual vs Automatic Steps
Not every pipeline step must be fully automatic.
Common patterns:
- Automatic:
- Run tests on every push.
- Build artifacts when tests pass.
- Deploy to dev or staging on specific branches.
- Manual:
- Approve deployment to production.
- Run database migrations that need human coordination.
- Roll back to a previous version.
Most CI/CD systems let you mark a job as "manual" or "requires approval" and still keep it part of the pipeline graph.
Handling Secrets in Pipelines
Pipelines usually need secrets:
- Database passwords
- API keys (for cloud providers, registries, email services)
- JWT signing keys
You must never put them in your repository.
Common approaches:
- Use the CI/CD platform's secret storage or "variables".
- Inject them into jobs as environment variables at runtime.
- Use limited-scope credentials:
- A registry token that can push images but not delete everything.
- A deploy key that can pull code but not push.
Example behavior:
- The build job uses
$REGISTRY_PASSWORDto login to the container registry. - The deploy job uses
$SSH_KEYto connect to the server. - None of these values appear in the repo or in logs.
Rule: Secrets must be stored securely by the CI/CD system and never committed to Git or printed in pipeline logs.
Designing a Simple Backend Pipeline
To tie everything together, here is a conceptual pipeline for a FastAPI + PostgreSQL project using Docker. The syntax is generic, but the flow can be implemented in GitHub Actions or GitLab CI/CD.
Stages:
testbuilddeploy_stagingdeploy_production
Jobs:
stages:
- test
- build
- deploy_staging
- deploy_production
jobs:
test:
stage: test
runs-on: linux
steps:
- checkout-code
- install-python: "3.11"
- pip-install: "requirements.txt"
- run: "pytest --maxfail=1 --disable-warnings -q"
build:
stage: build
needs: [test]
rules:
- if: branch in ["develop", "main"]
steps:
- checkout-code
- docker-login: "registry.example.com"
- docker-build: "registry.example.com/my-api:${COMMIT_SHA}"
- docker-push: "registry.example.com/my-api:${COMMIT_SHA}"
deploy-staging:
stage: deploy_staging
needs: [build]
rules:
- if: branch == "develop"
environment: "staging"
steps:
- ssh: "staging-server.example.com"
- run: |
docker pull registry.example.com/my-api:${COMMIT_SHA}
docker-compose up -d
deploy-production:
stage: deploy_production
needs: [build]
rules:
- if: tag matches "v*"
environment: "production"
when: manual
steps:
- ssh: "prod-server.example.com"
- run: |
docker pull registry.example.com/my-api:${COMMIT_SHA}
docker-compose up -dThis example is not tied to a specific CI/CD tool, but it shows how you can:
- Run tests first.
- Only build when tests pass.
- Deploy to staging automatically for development.
- Deploy to production from a tag, with a manual approval.
How CI/CD Pipelines Fit Into Your Backend Workflow
In a typical backend workflow:
- You create a branch, for example
feature/add-tasks-endpoint. - You commit and push changes.
- The CI pipeline runs tests and checks.
- You open a pull request.
- The pipeline runs again on the pull request.
- If everything is green, you merge to
developormain. - On merge, the CD part of the pipeline builds and possibly deploys.
- If a production deploy breaks something, you roll back using the pipeline, for example by redeploying a previous image.
The pipeline becomes a central part of your development process. Over time, you will add:
- More tests
- Security scans
- Database migration jobs
- Smoke tests after deploy
- Notifications to Slack or email on failures
Future chapters will show how to express these ideas in real CI/CD tools, specifically GitHub Actions and GitLab CI/CD.
Views: 7
KAHIBARO