Skip to content

Module 4: CI/CD

Prerequisites: Module 0 (Git, GitHub), Module 3 (Docker — build, images)

In a nutshell: You automate the verification and deployment of your code with GitHub Actions. On every push, a pipeline checks the code (lint), runs the tests, builds Docker images and pushes them to Docker Hub — without any human intervention.

In Module 3, you learned how to build Docker images and run them with docker compose. But who builds those images when you push your code? Who checks that the tests pass? Who pushes the images to Docker Hub? That’s the role of CI/CD — automating all of that.

The problem: Without CI/CD, every deployment is manual. Someone runs the tests on their machine, someone else does the build, a third person deploys via SSH. It’s slow, risky, and a source of human error. “I forgot to run the tests before deploying” — boom, production is broken.

It’s the difference between baking each pizza by hand and having an automated oven with a conveyor belt.

CI (Continuous Integration): On every push, we automatically check that the code is clean (lint) and that it works (tests). We don’t wait until the night before delivery to find out it’s broken.

CD (Continuous Delivery / Deployment):

  • Delivery = the code is ready to be deployed (manual button)
  • Deployment = the code is deployed automatically if everything is green

The analogy: An assembly line in a factory. You push the raw material (the code) onto the conveyor belt. Station 1 = quality control (lint). Station 2 = taste test (tests). Station 3 = packaging (build). Station 4 = delivery (deploy). If a defect is detected at any station, the line stops. This is the “fail fast” principle.

StageWhat it doesAnalogyTools (for us)
LintChecks code style and quality (syntax errors, unused variables, bad practices) — like a spell checker for codeSpell checkerRuff (Python), Oxlint (JS)
TestChecks that the code does what it shouldTasting the dishPytest
BuildCompiles/packages the applicationPackaging the dishDocker build
DeployPuts it in productionDelivering to the customerDocker push

These stages run in sequence. If lint fails, no tests. If tests fail, no build. Fail fast.

GitHub Actions runs workflows (pipelines) defined in YAML files in .github/workflows/.

TermWhat it is
WorkflowThe complete pipeline (the YAML file)
TriggerWhat triggers the workflow (on: push)
JobA group of steps that run on the same machine
StepAn individual action within a job
RunnerThe machine (a remote server) that runs the job. GitHub provides them for free — you don’t need to install anything.

A YAML file in .github/workflows/ describes the pipeline. Here’s a minimal example with each line explained:

# .github/workflows/ci.yml <- the file must be in this exact folder
name: CI Pipeline # The name shown in GitHub's Actions tab
on: # "on" = WHEN does this pipeline trigger?
push: # -> when someone pushes code
branches: [main] # -> but only on the "main" branch
pull_request: # -> OR when a Pull Request is created/updated
branches: [main] # -> targeting the "main" branch
jobs: # The list of jobs (groups of steps) to run
lint: # The job name (you choose whatever name you want)
runs-on: ubuntu-latest # On which machine to run? -> an Ubuntu server provided by GitHub
steps: # The list of steps for this job
- uses: actions/checkout@v4 # "uses" = use a pre-made action by someone else
# "actions/checkout" = an official GitHub action that downloads
# your code onto the runner (otherwise the runner is empty, it doesn't have your code)
# "@v4" = version 4 of this action
- name: Setup uv # "name" = a readable name for this step (shown in the UI)
uses: astral-sh/setup-uv@v4 # Installs uv on the runner (like you did on your machine)
- run: cd backend && uv run ruff check .
# "run" = execute a bash command directly
# (unlike "uses" which calls a pre-made action)

In a nutshell — the 4 keywords to remember:

KeywordWhat it doesExample
on:When the pipeline triggerson: push = on every push
runs-on:On which machineubuntu-latest = a free Ubuntu server from GitHub
uses:Use a pre-made actionactions/checkout@v4 = download the code
run:Execute a bash commandrun: uv run pytest = run the tests

uses vs run: uses calls a “plugin” (a ready-to-use action written by someone else — install Python, log in to Docker Hub, etc.). run executes a bash command that YOU write. If an action exists for what you want to do, use uses. Otherwise, run.

The backend uses Ruff (ultra-fast Python linter) and the frontend uses Oxlint (fast JS linter).

Check that the configs exist:

backend/pyproject.toml (already created):

[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]

frontend/oxlintrc.json (already created):

{
"rules": {
"no-unused-vars": "warn",
"no-console": "off",
"eqeqeq": "warn"
}
}

Test locally:

Fenêtre de terminal
# Backend
cd ~/devops-project/backend
uv run ruff check .
# All checks passed!
# Frontend
cd ~/devops-project/frontend
bunx oxlint .
# Finished in xxxms
Fenêtre de terminal
cd ~/devops-project/backend
uv run pytest
# ===== 7 passed in 0.5s =====

The project already provides the .github/workflows/ci.yml file. Before reading it, here are the syntaxes you’ll encounter:

Syntaxes to know for reading the file:

SyntaxWhat it meansExample
needs: lint”This job waits for the lint job to finish before starting” — this is how you create the order lint -> test -> build -> pushThe test job waits for lint
`run:`The `
${{ ... }}Insert a GitHub Actions variable. It’s like $VARIABLE in bash but with the ${{ }} syntax specific to GitHub Actions${{ github.sha }} = the commit hash
${{ secrets.NAME }}Access a secret stored in GitHub (Settings -> Secrets). The secret never appears in the logs${{ secrets.DOCKERHUB_TOKEN }}
with:Pass parameters to a uses: action. It’s like passing arguments to a functionwith: username: ... for the Docker login action
if:Run this job only if the condition is true. == means “is equal to”, && means “AND”if: github.ref == 'refs/heads/main' = only on the main branch

Here’s the complete file with comments:

name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# --- JOB 1: LINT (check code quality) ---
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Download the repo's code onto the runner
- name: Setup uv
uses: astral-sh/setup-uv@v4 # Install uv (Python manager)
- name: Lint backend (Ruff)
run: | # | = multiple lines of commands
cd backend
uv run ruff check .
- name: Setup Bun
uses: oven-sh/setup-bun@v2 # Install Bun (JS runtime)
- name: Lint frontend (Oxlint)
run: |
cd frontend
bunx oxlint .
# --- JOB 2: TEST (check that the code works) ---
test:
name: Test
runs-on: ubuntu-latest
needs: lint # Wait for the "lint" job to finish
steps:
- uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v4
- name: Run tests
run: |
cd backend
uv run pytest
# --- JOB 3: BUILD (build Docker images) ---
build:
name: Build Docker Images
runs-on: ubuntu-latest
needs: test # Wait for the "test" job to finish
steps:
- uses: actions/checkout@v4
- name: Build backend image
run: docker build -t devops-backend:${{ github.sha }} ./backend
# ${{ github.sha }} = the unique commit hash (e.g.: a1b2c3d)
# We use it as the image tag to know which commit it came from
- name: Build frontend image
run: docker build -t devops-frontend:${{ github.sha }} ./frontend
# --- JOB 4: PUSH (send images to Docker Hub) ---
push:
name: Push to Docker Hub
runs-on: ubuntu-latest
needs: build # Wait for the "build" job to finish
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# ^ This job only runs IF:
# - we're on the main branch (refs/heads/main)
# - AND it's a push (not a pull request)
# No need to push images for a PR -- we just want to verify it builds
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3 # Pre-made action to log in to Docker Hub
with: # "with" = the action's parameters
username: ${{ secrets.DOCKERHUB_USERNAME }} # Your Docker Hub username (stored in GitHub secrets)
password: ${{ secrets.DOCKERHUB_TOKEN }} # Your Docker Hub token (stored in GitHub secrets)
# Note: we rebuild the images here even though the "build" job already built them.
# Why? Each job runs on a different runner (a separate machine).
# The images built in the "build" job no longer exist here.
# The "build" job was to VERIFY that the build passes. Here, we build AND push.
- name: Build and push backend
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/devops-backend:latest ./backend
docker push ${{ secrets.DOCKERHUB_USERNAME }}/devops-backend:latest
# Image format: username/image-name:tag
# "latest" = the most recent version
- name: Build and push frontend
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/devops-frontend:latest ./frontend
docker push ${{ secrets.DOCKERHUB_USERNAME }}/devops-frontend:latest

On GitHub -> your repo -> Settings -> Secrets and variables -> Actions -> New repository secret:

The ci.yml file is already in the project. If you’ve pushed everything, the pipeline runs automatically.

Fenêtre de terminal
cd ~/devops-project
git add .
git commit -m "ci: pipeline GitHub Actions"
git push

Go to the Actions tab of your GitHub repo. You’ll see the pipeline running: Lint -> Test -> Build -> Push.

💡 If the Docker Hub push fails: check that the secrets are properly configured. The push job only runs on the main branch (not on pull requests).

NEVER put passwords or tokens in the code. Use GitHub secrets.

# In the workflow, access a secret:
${{ secrets.MY_SECRET }}
# Environment variables (non-sensitive):
env:
NODE_ENV: production
ToolSpecifics
GitLab CIVery popular in France, built into GitLab
JenkinsThe dinosaur — old but still everywhere
CircleCISaaS, easy to configure

Q: What is CI/CD? A: CI = automatically checking code on every push (lint, tests). CD = automatically deploying if everything passes. It reduces human errors and speeds up deliveries.

Q: What are the stages of a typical CI/CD pipeline? A: Lint (code quality) -> Tests -> Build (artifact construction) -> Deploy. Each stage blocks the next one if it fails.

Q: What is “fail fast”? A: If a stage fails, everything stops immediately. No need to build if the tests don’t pass. It saves time and resources.

Q: Where should you store secrets in a pipeline? A: In the CI/CD secrets (GitHub Secrets, GitLab Variables, etc.). Never in the code, never in committed YAML files.

Q: Difference between Continuous Delivery and Continuous Deployment? A: Delivery = ready to deploy but manual button. Deployment = automatic deployment. Most companies do Delivery.

Q: What is a runner? A: The machine (server) that runs the pipeline’s jobs. GitHub provides free runners (ubuntu-latest). You can also use your own runners.

Q: What is a blue/green deployment? A: A deployment strategy with two identical environments. “Blue” serves production, you deploy the new version to “green”, test it, then switch the traffic. If it breaks, you switch back in seconds. Advantage: instant rollback.

Q: What is a canary deployment? A: You deploy the new version to a small percentage of servers (e.g., 5%). You monitor the metrics. If everything’s fine, you gradually increase (25% → 50% → 100%). If it breaks, only 5% of users are impacted.

  • The pipeline must be fast. If the CI takes 20 min, devs stop using it. Parallelize independent jobs (lint backend || lint frontend), use caching (dependencies, Docker images).
  • Fail fast. Put the fastest stages first (lint < tests < build < deploy). No need to build for 5 min if lint fails in 10 seconds.
  • Never put secrets in the code. Use CI secrets (GitHub Secrets, GitLab Variables). If a secret was committed by mistake, change it immediately — a git rm is not enough (the history keeps everything).
  • A pipeline per branch, not just main. Run CI on Pull Requests too. The goal is to know if the code is broken BEFORE merging.
  • Reproducibility. Pin your action versions (actions/checkout@v4, not @latest). A pipeline that breaks on its own because a dependency was updated is a nightmare.
  • No git push --force from CI. CI should never destructively modify the source branch.
  • Forgetting actions/checkout@v4 -> The runner doesn’t have the code, everything fails.
  • Misspelled secrets -> ${{ secrets.DOCKER_HUB }} != ${{ secrets.DOCKERHUB_TOKEN }}. The name must match exactly.
  • Tests that pass locally but not in CI -> Often a dependency issue or missing environment variables.
  • Pipeline too slow -> Parallelize independent jobs (lint backend and lint frontend in parallel).
  • Committing secrets -> If it happens, change them IMMEDIATELY. Git keeps the history.
  • Deployment strategies: blue-green (two environments), canary (progressive deployment) — beyond the rolling update covered in the curriculum
  • Quality gates: test coverage thresholds, automated security analysis (SonarQube, Snyk) — increasingly required
  • ArgoCD: GitOps — the Git repo IS the source of truth for deployment. You push YAML, ArgoCD deploys automatically on K8s (requires having done Module 9 — Kubernetes)
  • You can explain CI (automatic verification) and CD (automatic deployment)
  • You know the 4 stages of a pipeline (lint -> test -> build -> deploy)
  • You understand the “fail fast” concept
  • The GitHub Actions pipeline runs on your repo (Actions tab)
  • You know how to configure secrets in GitHub (Settings -> Secrets)
  • You know what a runner is