Cloud & DevOps
Hands-onIntermediate 40 min· Containers and pipelines · 6 of 16

CI/CD with GitHub Actions

Set up a pipeline that tests your code on every push and builds a Docker image when tests pass. You will write a real GitHub Actions workflow, read its logs and fix a failing run.

What you'll build: a GitHub Actions workflow for a small Python API (the Brew & Bean menu service). On every push and pull request it installs dependencies and runs pytest, and on the main branch only it builds a Docker image. Prerequisites: a free GitHub account, Git basics (see *Git and GitHub: the team workflow*) and the Dockerfile idea from *Docker: images and containers*. Python testing itself is taught in the Testing track.

What CI and CD mean

Continuous Integration (CI) means every change is automatically built and tested as soon as it is pushed. Nobody has to remember to run the tests, and a broken change is caught within minutes, while the author still remembers what they did.

Continuous Delivery or Deployment (CD) means a change that passes the checks is automatically prepared for release (delivery) or released to users (deployment). Many teams keep a human approval step before production; that is still CD.

Think of a pipeline as a factory line with quality gates. If a gate fails, the product stops there.

GitHub Actions vocabulary

The words you need

TermMeaningIn our example
WorkflowA YAML file in .github/workflows/ that describes an automated processci.yml
Event (on)What starts the workflowpush, pull_request
JobA group of steps that runs on one fresh machinetest, docker
RunnerThe machine that runs a jobubuntu-latest (hosted by GitHub)
StepOne command (run) or one reusable action (uses)pytest

Jobs run in parallel by default. Each job starts on a clean runner, so nothing is shared between jobs unless you pass it explicitly. This is why you check out the code and install dependencies in every job that needs them.

Step 1: a tiny project to test

Create a folder menu-service and turn it into a Git repository. We need an app, a test, a requirements file and a Dockerfile.

requirements.txt
fastapi==0.115.0
httpx==0.27.2
pytest==8.3.3
uvicorn==0.30.6

Pinned versions keep CI runs repeatable. Newer versions will also work; update the pins when you wish. httpx is needed by FastAPI's test client.

main.py
from fastapi import FastAPI

app = FastAPI()

MENU = [
    {"id": "espresso", "name": "Espresso", "price": 120},
    {"id": "masala-chai", "name": "Masala Chai", "price": 90},
]


@app.get("/menu")
def get_menu():
    return MENU
test_main.py
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_menu_returns_items():
    response = client.get("/menu")
    assert response.status_code == 200
    assert len(response.json()) == 2


def test_prices_are_positive():
    for item in client.get("/menu").json():
        assert item["price"] > 0
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Run the tests on your own machine first. If they fail locally they will fail in CI, and fixing them here is faster.

Terminal
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
pytest -q
Expected result
..                                                                       [100%]
2 passed in 0.5s

Step 2: write the workflow

Create the file .github/workflows/ci.yml (the folder names matter exactly). Read the explanation after the code: each line has a reason.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

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

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest -q

  docker:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t menu-service:${{ github.sha }} .

on runs the workflow for pushes to main and for every pull request. permissions gives the jobs read-only access to the repo (least privilege). needs: test makes the docker job wait for tests, and if limits it to main.

Key lines explained:

actions/checkout@v4 copies your repository onto the runner (it starts empty). actions/setup-python@v5 installs Python, and cache: pip reuses downloaded packages between runs to save time. ${{ github.sha }} is an expression that GitHub replaces with the commit id, so every image gets a unique tag.

YAML cares about indentation. Use spaces only, never tabs, and keep the steps aligned.

Step 3: push and watch it run

Push your first run

  1. 1Create an empty repository on GitHub (no README) and copy its URL.
  2. 2In your folder run git init, then add a .gitignore containing .venv/ and __pycache__/.
  3. 3Run git add . and git commit -m "Add menu service and CI".
  4. 4Run git branch -M main, then git remote add origin <your-repo-url> and git push -u origin main.
  5. 5Open the repository on GitHub and click the Actions tab. You will see the run named CI.
  6. 6Click the run, then a job, then a step to read its log. A green tick means every step passed.
What you should see in the Actions tab
CI  (Add menu service and CI)
  test    passed   about 30 to 60 seconds
  docker  passed   waits for test, then builds the image
The exact timings vary.

Step 4: see a failure and fix it

A pipeline is only useful if it fails when the code is wrong. Create a branch, break something on purpose and open a pull request.

Terminal
git switch -c break-price
# edit main.py: change the Espresso price from 120 to -5
git commit -am "Change price"
git push -u origin break-price

Open a pull request on GitHub. The Checks section shows the CI run in red. Open the log of the Run tests step: you will find the assertion item["price"] > 0 failing, with the line of code. Fix the price, commit, push, and the run turns green on its own. The docker job did not run on this branch because of the if condition.

Protect main with required checks

In the repository go to Settings, then Branches (or Rules), and add a rule for main that requires the CI check to pass before merging. Now broken code cannot reach main. Available options depend on your plan and repository type; check the GitHub docs if a setting is missing.

Secrets and safety

Pipelines often need passwords or tokens, for example to push an image to a registry. Never write them in the YAML file or commit them. Store them under Settings, Secrets and variables, Actions, and read them as ${{ secrets.NAME }}. GitHub hides secret values in logs.

Other good habits: keep permissions as small as possible, pin actions to a major version such as @v4, and be careful with workflows triggered by pull requests from forks, which do not receive your secrets by default.

Example step (not part of ci.yml)
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

Pushing an image needs a registry account and the two secrets created first. We stop at building here; deploying to a cloud service is covered later in the track.

A leaked secret is a compromised secret

If you ever commit a token, deleting the file is not enough, because it stays in Git history. Revoke the token at the provider immediately and create a new one.

Making pipelines faster and tidier

Common improvements

GoalHow
Faster installscache: pip in setup-python (or cache: npm for Node)
Test several versionsstrategy: with matrix: and python-version: ["3.11", "3.12"] on the job
Stop duplicate runsa concurrency: group with cancel-in-progress: true
Run only when neededa paths: filter under on: push
Keep files out of Git.gitignore, and never commit .env
Prompt · Explain a failing pipeline
My GitHub Actions job failed. Explain in simple terms what went wrong and give the smallest fix.

Workflow file:
```
[paste ci.yml]
```

Failing log lines:
```
[paste the last 30 lines of the failed step]
```

What to expect: Check that the answer points to a specific line, and run the fix locally if you can before pushing.

How this comes up in interviews

Expect: the difference between CI, continuous delivery and continuous deployment; what a runner is; how you keep secrets out of code; how you would speed up a slow pipeline; and what you do when a build is red on main. A strong answer to the last one is: stop merging, find the commit, and either revert it or fix forward quickly, then ask why the check did not catch it earlier.

Try it: Extend the pipeline

Add a lint step before the tests using pip install ruff and ruff check .. Then add a matrix so tests run on Python 3.11 and 3.12. Push on a branch and confirm both matrix jobs appear.

Self-check

  1. My tests pass on my laptop before I push
  2. The workflow file is under .github/workflows/ and ends in .yml
  3. I can open a run and find the failing step
  4. No secret appears in any file in the repository
  5. The image is built only on main

Key takeaways

  1. CI runs your tests automatically on every change; CD prepares or ships the result.
  2. A workflow has events, jobs, runners and steps; each job starts on a clean machine.
  3. needs orders jobs and if limits where they run.
  4. Store tokens in repository secrets, never in YAML or Git.
  5. Make the CI check required on main so broken code cannot be merged.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does every job in a workflow usually begin with actions/checkout?

2. What does needs: test do on the docker job?

3. Where should a registry password be stored?

4. A pull request shows a red CI check. What is the best first action?

5. What is the difference between continuous delivery and continuous deployment?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free