DevOps module 2: CI/CD pipelines and containers
Fourteen sessions to make the machine do the checking: a small application with its tests, a pipeline that runs them on every push, a container image built the right way, and a registry to ship it. The module ends with an application that builds, tests and packages itself.
The examples use GitHub Actions syntax. Forgejo Actions is designed to feel familiar but is not guaranteed compatible: on Forgejo, workflows live in .forgejo/workflows/, runs-on names one of your runner's labels (often docker), and the Forgejo Actions guide lists the differences. The site's own article Your own CI on Forgejo tells the traps met in practice.
Day 15 · 17/11 — Continuous integration, delivery, deployment
Goal: tell CI, continuous delivery and continuous deployment apart.
Theory 1/2: continuous integration
Grady Booch used the words "continuous integration" in his 1991 object-oriented design book; Extreme Programming (Kent Beck, late 1990s) made it a practice, and Martin Fowler's article (2000, revised since) described it: everyone merges into the main line at least daily, and every merge is built and tested automatically. A broken build is fixed first, before anything else. The point is to find integration problems when they are small.
Which team practises continuous integration?
- Each developer works for a month on a branch, then everything is merged
- Everyone merges small changes daily, and each merge runs the tests
Answer
Frequent integration plus automatic tests.
Theory 2/2: delivery versus deployment
Continuous Delivery (Jez Humble and David Farley, 2010) goes further: every change that passes the pipeline is releasable; a human decides when it goes to production, with one click. Continuous deployment removes that click: every change that passes goes to production by itself. The pipeline is the automated chain: build, test, package, deploy.
The pipeline is green and the release waits for a manager's click. Is it…
- continuous delivery
- continuous deployment
Answer
Delivery: releasable at any time, released on a decision.
The build of main is red. In a CI team, what comes first?
- Merging more features
- Disabling the failing test
- Fixing (or reverting) the change that broke it
Answer
A red main blocks everyone; fixing it is the priority.
Practice (30 min)
- Read the first part of Martin Fowler's article on continuous integration, and write its practices as a checklist in your notebook.
- Draw the pipeline you want by day 28: push → lint → test → build image → push image → (later) deploy. For each box, write what makes it fail.
- Create a new public repository
hello-opson your forge and clone it.
With your AI: "Give me six short situations from software teams and, for each, ask me whether it is continuous integration, continuous delivery, continuous deployment, or none. Wait for my answer each time and justify the right answer in one sentence."
Recap (10 min)
Three lines: CI, delivery, deployment. Then redraw your target pipeline from memory.
Day 16 · 18/11 — YAML and your first workflow
Goal: write a workflow that runs on every push, and read its log.
Theory 1/2: YAML in five rules
YAML describes data with indentation: spaces only, never tabs; key: value for a map; - item for a list; | for a multi-line text; # for a comment. A wrong indentation changes the meaning without an error, which is why linters exist (yamllint).
name: ci
on: [push]
jobs:
hello:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "Hello from $GITHUB_REPOSITORY"
- run: |
uname -a
ls -la
Can YAML be indented with tabs?
- Yes
- No, spaces only
Answer
Spaces only; tabs are refused.
Theory 2/2: workflow, job, step
GitHub Actions appeared in 2018 and became generally available in November 2019. A workflow (one file in .github/workflows/) reacts to events (on: push, pull_request, schedule…). It holds jobs, which run in parallel by default on a runner (runs-on), unless one needs another. A job is a list of steps: a shell command (run) or a reusable action (uses, like actions/checkout, which clones your repository into the runner). A step that exits non-zero fails the job.
Without actions/checkout, does a job see your code?
- No, the runner starts empty
- Yes, always
Answer
The runner starts from a clean machine; checkout clones the repository.
Which key makes a job wait for another one?
Answer
needs:, for example needs: test.
Practice (30 min)
- In
hello-ops, create.github/workflows/ci.yml(or.forgejo/workflows/ci.yamlwith your runner's label) with the workflow above. Commit and push. - Open the Actions tab, then the run, then each step: read the log.
- Add a step
run: exit 1, push, see the run fail and which step is red. Remove it. - Add a second job
afterwithneeds: hellothat prints "done", and check the order in the run's graph.
With your AI: "Here is my first GitHub Actions workflow: [paste ci.yml]. For each line, tell me in a few words what it does, then ask me two questions to check I understood the difference between a job and a step."
Recap (10 min)
Three lines: YAML's rules, workflow / job / step, what fails a job. Then rewrite the workflow from memory.
Day 17 · 19/11 — Tests in the pipeline
Goal: give the application its tests and make the pipeline run them on every push and pull request.
Theory 1/2: what to test, where
The test pyramid (Mike Cohn, Succeeding with Agile, 2009): many fast unit tests (one function), fewer integration tests (pieces together: the app and its database), few slow end-to-end tests (the whole system as a user). In a pipeline, the cheapest checks run first: lint (style and obvious mistakes), then unit tests, then the rest, so a failure is reported in seconds. A test that never fails proves nothing: break the code once on purpose and watch it go red.
Which tests should be the most numerous?
- Unit tests
- End-to-end tests
Answer
Unit tests: fast, precise, cheap.
Why run the linter before the slow tests?
- It is mandatory
- To fail fast on cheap mistakes
Answer
Fast feedback: seconds, not minutes.
Theory 2/2: the application
A tiny web service in Python with the standard library only, so nothing hides how it works. app.py:
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
VERSION = os.environ.get("APP_VERSION", "dev")
def route(path):
if path == "/health":
return 200, {"status": "ok"}
if path == "/":
return 200, {"message": "hello", "version": VERSION}
return 404, {"error": "not found"}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
code, body = route(self.path)
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
if __name__ == "__main__":
HTTPServer(("0.0.0.0", int(os.environ.get("PORT", "8080"))), Handler).serve_forever()
test_app.py, run by pytest:
from app import route
def test_health():
assert route("/health") == (200, {"status": "ok"})
def test_unknown_path_is_404():
assert route("/nothing")[0] == 404
What code does route("/admin") return?
Answer
404: only / and /health exist.
Practice (30 min)
- Type both files into
hello-ops. Locally:python3 -m venv .venv && . .venv/bin/activate && pip install pytest && pytest -q. Start the app withpython3 app.pyandcurl -i http://127.0.0.1:8080/health. - Replace the workflow's steps:
on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Install tools run: | python3 -m venv .venv .venv/bin/pip install pytest ruff - name: Lint run: .venv/bin/ruff check . - name: Unit tests run: .venv/bin/pytest -q - Push; then open a pull request that breaks
route(return 500 for/health): the check turns red on the pull request. Close it without merging. - Add a
.gitignorewith.venv/and__pycache__/.
With your AI: "Here is my function route() and its two tests: [paste both]. Propose three more unit tests that would catch a real bug (not duplicates of mine), each with one sentence on the bug it catches. I will write them myself."
Let an agent do it: agents write unit tests well when told what behaviour matters. Instruction: "Add pytest tests for route() covering / and an unknown path with a trailing slash; do not change app.py; run pytest and show the output." You still check: each new test fails when you break the code it covers (mutate one line, run, revert).
Recap (10 min)
Three lines: the pyramid, why lint first, why a test must be seen failing once. Then rewrite test_health from memory.
Day 18 · 20/11 — Matrix, cache, artifacts, pinned actions
Goal: test on two Python versions, keep a test report, and pin a third-party action by its commit.
Theory 1/2: matrix and artifacts
A matrix runs the same job once per value: here, inside two official Python container images. An artifact is a file the run keeps after it ends (a report, a binary); a cache keeps downloads between runs to save time.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.12", "3.13"]
container: python:${{ matrix.python }}-slim
steps:
- uses: actions/checkout@v7
- run: pip install pytest && pytest -q --junitxml=report.xml
With the matrix above, how many test jobs run?
Answer
Two, one per Python version.
Theory 2/2: a tag can lie, a commit cannot
uses: owner/action@v4 runs whatever the tag v4 points to today, and a tag can be moved. In March 2025 an attacker repointed the tags of the popular action tj-actions/changed-files to a commit that printed the runners' secrets into the logs (CVE-2025-30066); in March 2026 most tags of aquasecurity/trivy-action, a security scanner's own action, were force-pushed to a credential stealer (CVE-2026-33634). The defence: pin third-party actions to a full commit SHA, with the version as a comment, and give the workflow's token the least permissions (permissions: contents: read).
permissions:
contents: read
steps:
- uses: some-org/some-action@<40-character commit SHA> # v2.3.1
Find the SHA of a tag yourself with git ls-remote --tags https://github.com/some-org/some-action, never from a blog post.
Which reference cannot be changed silently by the action's owner?
@v4@a full commit SHA@main
Answer
A commit SHA names one content forever. Tags and branches move.
What did the tj-actions compromise do?
- Printed the CI secrets into the build logs
- Deleted the repositories
Answer
It dumped secrets from the runner's memory into world-readable logs.
Practice (30 min)
- Turn your test job into the matrix above; push; see two jobs.
- Keep the report: add a step
uses: actions/upload-artifact@v4withname: report-${{ matrix.python }}andpath: report.xml(check the current major version on its page). On Forgejo, read the guide first: its artifact actions differ. - Add
permissions: contents: readat the top of the workflow. - Look up the SHA of the tag you use for
actions/checkoutwithgit ls-remote --tags, pin it, keep the version as a comment, push.
With your AI: "Audit this workflow for supply-chain risks: [paste ci.yml]. List each third-party action, whether it is pinned by SHA, the permissions the token gets, and the one change you would make first. Do not invent SHAs: tell me how to look them up."
Let an agent do it: tools and agents can pin every action of a repository at once. Instruction: "Pin every uses: to the commit SHA of its current tag, looked up with git ls-remote, and keep the tag as a comment; list the lookups you ran." You still check two SHAs yourself with git ls-remote: a wrong SHA is exactly what an attacker would want you to accept.
Recap (10 min)
Three lines: matrix, artifact, why pin by SHA. Then retell the tj-actions story in two sentences.
Day 19 · 21/11 — Runners: hosted and self-hosted
Goal: know where your jobs run, and when a runner on your own machine is a risk.
Theory 1/2: who runs the job
A runner is the program that takes jobs from the forge and executes them. GitHub provides hosted runners (a fresh virtual machine per job, free on public repositories within limits). You can also register a self-hosted runner on your machine: GitHub's runner, or on Forgejo the Forgejo Runner, which runs each job in a container. The runs-on label picks which runner gets the job.
What decides which runner takes a job?
- The job's name
- The
runs-onlabel matching a runner's labels
Answer
Labels. "No matching runner" means no online runner has that label.
Theory 2/2: the risk of a self-hosted runner
A job runs whatever code the workflow says. On a public repository, anyone can open a pull request with a modified workflow; on a self-hosted runner, that code would run on your machine, with its network and files. GitHub's documentation advises against self-hosted runners on public repositories. Isolation helps (a container or a throwaway VM per job, no Docker socket mounted, no secrets on the host), and so does requiring approval before running workflows from outside contributors.
Why is a self-hosted runner on a public repository risky?
- A stranger's pull request can run code on your machine
- It is slower
Answer
Untrusted code runs on your infrastructure.
A runner container gets the host's Docker socket mounted. What can a job do?
- Only build images
- Control the host's Docker, so in practice become root on the host
Answer
Access to the Docker socket is equivalent to root on the host.
Practice (30 min)
- Read, in one of your runs, the "Set up job" step: which image, which runner version, which labels?
- Read the GitHub page on self-hosted runners and its security section; note three risks in your notebook.
- Optional, if you host Forgejo (see Homelab 8): register a Forgejo Runner on a private test repository, with a label of your own, and send it one job.
With your AI: "I want a self-hosted CI runner at home for private repositories only. List the isolation measures in order of importance (at most seven), each with the attack it prevents. Mark anything you are unsure of as 'check in the docs'."
Recap (10 min)
Three lines: hosted versus self-hosted, what labels do, why not on a public repository. Then name two isolation measures without notes.
Day 20 · 22/11 — Containers: what they are
Goal: explain what isolates a container, and run your first ones.
Theory 1/2: a process with walls
A container is not a small virtual machine: it is a process of the host, sharing the host's kernel, with walls around it. The walls are Linux features: namespaces (it sees its own processes, network, file tree, hostname) and cgroups (it gets a capped share of CPU and memory). The idea is old: chroot (Unix V7, 1979), FreeBSD jails (2000), cgroups in Linux 2.6.24 (2008), LXC (2008). Docker, shown by Solomon Hykes of dotCloud at PyCon in March 2013, made it easy with an image format and a command line; the Open Container Initiative (2015) standardised images and runtimes.
Does a container have its own kernel?
- Yes, like a virtual machine
- No, it shares the host's kernel
Answer
It shares the kernel; a VM runs its own.
Which Linux feature limits a container's CPU and memory?
Answer
cgroups (control groups). Namespaces isolate what it sees.
Theory 2/2: image and container
An image is a read-only template (files plus metadata: what to run, which port). A container is a running instance of an image, with a thin writable layer that disappears with it. One image, many containers.
docker run --rm hello-world
docker run --rm -it debian:trixie bash # a shell inside, gone on exit
docker run -d --name web -p 8081:80 nginx
docker ps; docker logs web; docker stop web; docker rm web
You write a file inside a container started with --rm, then exit. The file is…
- gone
- saved in the image
Answer
Gone with the container's writable layer. Data that matters goes in a volume (day 23).
Practice (30 min)
- Install Docker Engine following the official guide for your distribution; check
docker version. - Run
hello-world, then a Debian shell: inside, runps auxandhostname, and compare with the host. - Start nginx on port 8081,
curl -I http://127.0.0.1:8081, readdocker logs web, stop and remove it. - On the host, find the container's process:
ps aux | grep nginxwhile it runs. It is just a process.
Adding your user to the docker group spares you sudo, but that group is equivalent to root on the machine (yesterday's lesson). On a shared machine, consider Docker's rootless mode or Podman.
With your AI: "Explain the difference between a container and a virtual machine to me at three levels: one sentence for a beginner, one paragraph mentioning namespaces and cgroups, and one table comparing kernel, boot time, isolation and size. Then quiz me with three questions."
Recap (10 min)
Three lines: namespaces, cgroups, image versus container. Then redo the nginx sequence without notes.
Day 21 · 23/11 — The Dockerfile and its layers
Goal: package hello-ops in an image, and order the Dockerfile so rebuilds are fast.
Theory 1/2: the instructions
FROM python:3.13-slim # base image
WORKDIR /app # working folder
COPY app.py . # files from the build context
EXPOSE 8080 # documents the port
CMD ["python", "app.py"] # what the container runs
RUN executes a command at build time (install packages); ENV sets a variable; CMD is the default command, ENTRYPOINT the fixed one. docker build -t hello-ops:dev . builds; the . is the build context sent to the builder, filtered by .dockerignore.
Which instruction runs at build time?
CMDRUN
Answer
RUN at build; CMD when the container starts.
Theory 2/2: layers and cache
Each instruction makes a layer. On a rebuild, Docker reuses every layer until the first one whose input changed, and rebuilds everything after it. So put what changes rarely (system packages, dependencies) before what changes often (your code):
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # cached while requirements do not change
COPY . . # your code, last
You change one line of app.py. With the order above, is pip install run again?
- No, its layer comes from the cache
- Yes, everything is rebuilt
Answer
Only the layers from COPY . . onwards are rebuilt.
Which file keeps .git and .venv out of the build context?
Answer
.dockerignore.
Practice (30 min)
- Write the first Dockerfile above in
hello-ops, plus a.dockerignorewith.git,.venv,__pycache__. docker build -t hello-ops:dev ., thendocker run --rm -p 8080:8080 hello-ops:devandcurl http://127.0.0.1:8080/.- Run it with
-e APP_VERSION=1.0.0: the version shows in the answer. - Read the layers with
docker history hello-ops:dev. Changeapp.py, rebuild, and watch which steps sayCACHED.
With your AI: "Here is my Dockerfile: [paste it]. Explain the cache effect of each instruction's position, and tell me which instruction would invalidate the cache most often. No rewrite, just the reasoning."
Let an agent do it: an agent can write a first Dockerfile for any small project. Instruction: "Write a Dockerfile for this repository: slim official base, dependencies before code, .dockerignore included; build it and run the health check." You still check: the image starts, /health answers, and docker history shows nothing that should not be there (a .env, the .git folder).
Recap (10 min)
Three lines: build context, layers, cache order. Then rewrite the Dockerfile without notes.
Day 22 · 24/11 — Good images: small, non-root, multi-stage
Goal: make the image smaller, run it as a normal user, and refer to it by digest.
Theory 1/2: multi-stage and non-root
A multi-stage build uses one stage to build or test and copies only the result into a clean final stage: tools and test files never reach production. And by default a container runs as root: add a user and switch to it with USER, so a flaw in the app does not give root inside the container.
FROM python:3.13-slim AS test
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir pytest && pytest -q
FROM python:3.13-slim
WORKDIR /app
COPY --from=test /app/app.py .
RUN useradd --system --uid 10001 app
USER app
EXPOSE 8080
CMD ["python", "app.py"]
In this Dockerfile, is pytest in the final image?
- Yes
- No, only
app.pyis copied from the test stage
Answer
The final stage starts again from the clean base.
Which instruction makes the container run as another user?
Answer
USER, after creating the user.
Theory 2/2: tags and digests
A tag (python:3.13-slim, latest) is a movable name, like a Git tag. A digest (python@sha256:…) names one exact content. Pin the digest when you need a build you can reproduce; let a bot (Renovate, Dependabot) propose the updates. latest only means "the last one pushed without a tag", not "the newest stable".
Which reference guarantees the same image tomorrow?
@sha256:…:latest
Answer
The digest. latest can change at any push.
Practice (30 min)
- Replace your Dockerfile with the multi-stage one; build; break a test and see the build fail at the test stage; fix it.
- Check the user:
docker run --rm hello-ops:dev idprints uid 10001. - Compare sizes:
docker images, then tryFROM python:3.13(not slim) and compare. - Find the digest of your base:
docker inspect --format '{{index .RepoDigests 0}}' python:3.13-slim, and note it.
With your AI: "Review my Dockerfile against these criteria, and answer as a table with columns criterion / met or not / line: non-root user, multi-stage, no secrets, slim base, dependencies before code, pinned base. Here it is: [paste it]."
Let an agent do it: an agent can harden a Dockerfile against that checklist. You still check by running it: id inside the container, the size, the health check, and that the tests still run in the build.
Recap (10 min)
Three lines: multi-stage, non-root, tag versus digest. Then rewrite the final stage without notes.
Day 23 · 25/11 — Ports, volumes, networks, debugging
Goal: keep data across containers, connect two containers by name, and debug one that will not start.
Theory 1/2: ports and volumes
-p 8080:80 publishes the container's port 80 on the host's port 8080 (host first). A volume keeps data outside the container's writable layer: a named volume (-v pgdata:/var/lib/postgresql/data) is managed by Docker; a bind mount (-v "$PWD/conf:/etc/app:ro") shows a host folder inside, here read-only.
-p 3000:8080: on which host port do you reach the app?
- 8080
- 3000
Answer
Host port first: 3000 on the host goes to 8080 in the container.
Theory 2/2: networks and debugging
Containers on the same user-defined network reach each other by name: Docker runs a DNS for them. To debug: docker ps -a (did it exit? with which code?), docker logs, docker inspect (configuration, mounts, network), docker exec -it name sh (a shell inside a running container).
docker network create lab
docker run -d --name db --network lab -e POSTGRES_PASSWORD=dev-only postgres:17
docker run --rm --network lab postgres:17 pg_isready -h db
A container exits right after starting. Which command shows what it printed?
Answer
docker logs name (works on stopped containers too).
How does the second container find db?
- Docker's DNS on the user-defined network resolves the name
- Through the host's /etc/hosts
Answer
On a user-defined network, container names resolve.
Practice (30 min)
- Run the three commands above; then
docker exec -it db psql -U postgres -c "create table t(x int); insert into t values (42);". - Remove the container, start it again without a volume: the table is gone. Start again with
-v pgdata:/var/lib/postgresql/data, create the table, remove and recreate the container: the table stays. (Check the data path for your PostgreSQL version on the image page.) - Break something on purpose: start
hello-ops:devwith-e PORT=abc. Find why it exits withdocker ps -aanddocker logs. - Clean up:
docker rm -f db,docker network rm lab,docker volume ls.
With your AI: "My container exits immediately. Here are docker ps -a and docker logs: [paste both]. Give me the three most likely causes ranked, the command that would confirm each, and nothing else."
Let an agent do it: an agent with Docker access can run the whole ps / logs / inspect routine and summarise. Ask it to quote the log line it bases its diagnosis on. You still check that line yourself, and you apply the fix.
Recap (10 min)
Three lines: port order, named volume versus bind mount, the four debugging commands. Then redo the volume test without notes.
Day 24 · 26/11 — Docker Compose
Goal: describe a two-service stack in one file and start it with one command.
Theory 1/2: the compose file
Compose describes containers, networks and volumes in a YAML file, compose.yaml, following the Compose Specification. The command is docker compose (Compose v2, a Docker plugin); the old docker-compose and the top-level version: key are obsolete. Each service joins a default network and reaches the others by service name.
services:
app:
build: .
ports:
- "8080:8080"
environment:
APP_VERSION: dev
depends_on:
cache:
condition: service_healthy
cache:
image: redis:8
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 5
How does app reach the cache?
- By the host's IP
- By the service name
cache
Answer
Compose puts both on one network where service names resolve.
Theory 2/2: the everyday commands
docker compose up -d --build # build and start in the background
docker compose ps # state and health
docker compose logs -f app # follow one service
docker compose down # stop and remove (add -v to delete volumes)
depends_on with condition: service_healthy waits until the dependency's health check passes, not only until it starts. Secrets go in an .env file listed in .gitignore, never in compose.yaml.
Which command stops the stack and deletes its named volumes?
Answer
docker compose down -v: careful, the data goes with it.
Without a health check, depends_on waits…
- only until the container is started, not until it is ready
- until the service answers requests
Answer
Started is not ready; the health check makes the difference.
Practice (30 min)
- Write the compose file above in
hello-ops(check the current Redis tag on its image page). docker compose up -d --build,docker compose psuntilcacheis healthy,curl http://127.0.0.1:8080/health.- Add a health check to
apptoo, using Python:test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]. docker compose logs -f, thendocker compose down.
With your AI: "Here is my compose.yaml: [paste it]. Check it against the Compose Specification: obsolete keys, missing health checks, secrets in clear, ports published that need not be. Answer as a list of at most six findings with the key concerned. Say 'not sure' when you are not."
Let an agent do it: an agent can turn a set of docker run commands into a compose file. Give it the exact commands you ran on day 23. You still check: docker compose config validates it, the stack becomes healthy, and no password ended up in the file.
Recap (10 min)
Three lines: what Compose replaces, how services find each other, started versus healthy. Then rewrite the compose file from memory.
Day 25 · 27/11 — Registries and tags
Goal: push your image to a registry with tags that say what is inside.
Theory 1/2: registries
A registry stores and serves images: Docker Hub (the default when no host is named), GitHub's ghcr.io, Forgejo's built-in container registry, or your own. An image's full name is host/owner/name:tag, for example ghcr.io/alice/hello-ops:1.0.0. You log in with a token limited to packages, never your password, passed on stdin so it does not land in the shell history.
echo "$TOKEN" | docker login ghcr.io -u alice --password-stdin
docker tag hello-ops:dev ghcr.io/alice/hello-ops:1.0.0
docker push ghcr.io/alice/hello-ops:1.0.0
docker pull nginx: from which registry?
- ghcr.io
- Docker Hub
Answer
Without a host, Docker Hub (docker.io/library/nginx).
Which docker login option reads the token from stdin?
Answer
--password-stdin.
Theory 2/2: a tagging strategy
Give each build an immutable tag that points back to the code: the commit SHA (:3f9c2ab); give releases a semantic version (:1.4.0). Never redeploy a different content under the same version tag. A movable tag like :main is fine for "the last build of main", never for production.
Production runs :latest and breaks after a restart. Why is that hard to debug?
- You do not know which build
latestwas before and after - Because latest is slow
Answer
A movable tag hides what changed. Deploy by SHA or version.
Practice (30 min)
- Create a token on your forge with only the packages / registry scope; keep it in your password manager.
- Log in, tag
hello-ops:devwith your commit SHA (git rev-parse --short HEAD) and with0.1.0, push both. - See the package on the forge; make it public if your repository is.
- Remove the local image and run it from the registry:
docker run --rm -p 8080:8080 ghcr.io/you/hello-ops:0.1.0.
With your AI: "Propose a container tagging scheme for a small app deployed from main, with releases tagged in Git as v1.2.3. Give a table: event, tags pushed, who may deploy it. Then give me two mistakes teams make with tags and how mine avoids them."
Recap (10 min)
Three lines: the full image name, login with a token on stdin, SHA and version tags. Then redo the tag and push without notes.
Day 26 · 28/11 — The pipeline builds the image, and prompting for pipelines
Goal: make the pipeline build and push the image after the tests pass on main, and write a prompt that yields a pipeline you can trust.
Theory 1/3: a build job after the tests
image:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- name: Build and push
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
image=ghcr.io/${{ github.repository }}:${{ github.sha }}
echo "$TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker build -t "$image" .
docker push "$image"
needs: test builds only after the tests; if: restricts pushes to main, so a pull request tests but publishes nothing; the token gets packages: write for this job only. On Forgejo, use a secret you create holding a registry token, and your instance's host. (The repository name must be lowercase for a registry.)
A pull request runs this workflow. Is an image pushed?
- Yes
- No, the
if:limits the job to main
Answer
Tests run on the pull request; publishing only on main.
Why packages: write on this job rather than the whole workflow?
- Least privilege: the test job does not need to publish
- It is required syntax
Answer
Each job gets only the rights it uses.
Theory 2/3: prompting for a pipeline
A weak prompt: "write a GitHub Actions pipeline for my Python app with Docker". What is wrong: the model does not know your files, your registry, when to publish, or your security rules; it will add actions you did not ask for, at versions it remembers, maybe outdated or invented.
The edited prompt:
Role: you are a CI engineer who follows least privilege.
Context: GitHub Actions; repo has app.py, test_app.py, Dockerfile
(multi-stage, tests in the build); registry ghcr.io.
Task: one workflow .github/workflows/ci.yml.
Constraints: test on every push and pull request; build and push
only on main, after tests; tag with the commit SHA; top-level
permissions contents: read; packages: write only on the push job;
no third-party action except actions/checkout; no secrets in clear.
Input: here is my current ci.yml: [paste].
Output: the full file, then a list of each change and why.
Check: I will run actionlint and open a pull request to see it
test without pushing.
Which line of the edited prompt prevents the model from adding unknown actions?
- The role
- The output format
- "no third-party action except actions/checkout"
Answer
That constraint; every extra action is code you would have to trust.
Fix this prompt: "Make my pipeline faster." What is the most useful addition?
- "Please, it is urgent"
- The workflow file, the duration of each step from the last run, and "do not remove any test"
Answer
The real inputs and the constraint that protects what matters.
Practice (30 min)
- Add the
imagejob toci.yml; push tomain; check the package appears with the commit SHA as tag. - Open a pull request: check that it tests and does not push.
- Lint the workflow with actionlint (it has a Docker image:
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest).
With your AI: send the edited prompt above with your real ci.yml. Compare its answer with yours line by line; run actionlint on it; keep only the changes you can explain.
Let an agent do it: an agent in your repository can now maintain the pipeline. Instruction: "Add a job that runs shellcheck on scripts/*.sh before the tests; keep permissions minimal; run actionlint; open a pull request." You still check the run on the pull request, the permissions block, and that nothing is pushed from a pull request.
Recap (10 min)
Three lines: needs, if on main, per-job permissions; and the parts of a pipeline prompt. Then rewrite the image job from memory.
Day 27 · 29/11 — Releasing: rolling, blue-green, canary, rollback
Goal: choose a release strategy and know how to undo a release.
Theory 1/2: four strategies
- Recreate: stop the old version, start the new. Simple, with downtime.
- Rolling update: replace instances a few at a time; both versions run together for a while (Kubernetes' default, day 39).
- Blue-green: the new version (green) runs beside the old (blue); traffic switches at once; switching back is the rollback.
- Canary: a small share of traffic goes to the new version; you watch the metrics, then widen or abort.
Feature flags separate deploying code from releasing a feature: the code ships switched off.
5 % of users get the new version while you watch the error rate. Which strategy?
- Recreate
- Blue-green
- Canary
Answer
Canary: a small exposed share, then widen.
During a rolling update, can old and new versions serve at the same time?
- No
- Yes: the app and its database changes must tolerate both
Answer
Yes, so changes must be backward compatible.
Theory 2/2: rollback and database changes
A rollback redeploys the previous image tag: one more reason for immutable tags. Database schema changes do not roll back that easily, so they are split into compatible steps (the expand and contract pattern): add the new column, deploy code using both, migrate the data, then remove the old column in a later release.
Why add a new column before removing the old one, in two releases?
- So the previous version still works if you roll back
- To use more disk
Answer
Each step stays compatible with the version before it.
Practice (30 min)
- Simulate blue-green with two containers: run tag A on port 8081 and tag B on port 8082 (
-e APP_VERSIONdifferent). Your "router" is which port you curl; switch, then switch back. - Write in your notebook the rollback procedure for
hello-ops: which command, which tag, how you check it worked (/shows the version). - For each strategy, write one case where you would choose it.
With your AI: "I deploy a small API with Docker Compose on one server. Compare recreate, blue-green and canary for this setup in a table (downtime, rollback speed, what I need to build it), then ask me which I would start with and why."
Recap (10 min)
Three lines: the four strategies, deploy versus release, expand and contract. Then redo the blue-green switch without notes.
Day 28 · 30/11 — Module 2 deliverable: an app that ships itself
Goal: deliver hello-ops with its pipeline, its image and its documentation, and check what you learnt.
Theory 1/2: what the deliverable proves
A reader of your repository should see, without asking: every pull request is linted and tested; main produces an image tagged by commit in a registry; the image is small and runs as non-root; the stack starts with one command; the README says how to run, test and roll back.
A badge in the README says "build passing". What does it prove?
- That the app works in production
- That the last pipeline run on that branch passed its checks
Answer
Only what the pipeline checks: as good as its tests.
Theory 2/2: module review
Every change that passes goes to production automatically. This is…
- continuous deployment
- continuous delivery
Answer
Deployment; delivery keeps a human decision.
Which reference is safest for a third-party action?
@main@v3- a full commit SHA
Answer
The SHA; tags and branches can be repointed.
You change only your code. Which Dockerfile order keeps the dependency layer cached?
COPY . .thenpip install- copy requirements,
pip install, thenCOPY . .
Answer
Dependencies before code.
Endless generated exercise (needs JavaScript): HTTP codes your health checks will meet.
Practice (30 min)
- Check the list: workflow with lint, tests (matrix), image job on main with SHA tags; actions pinned; minimal permissions; multi-stage non-root Dockerfile;
compose.yamlwith health checks;.dockerignoreand.gitignore. - Write the README: what the app does, a diagram of the pipeline (text is fine), how to run with Compose, how to run the tests, how to roll back.
- Tag a release:
git tag v0.1.0 && git push --tags, and note the image that matches it. - Add the repository to your portfolio list: deliverable 2.
With your AI: "Here is my repository tree, my ci.yml and my Dockerfile: [paste]. Act as a senior reviewer: list what a production team would still require before trusting this pipeline (at most seven points, most important first). Do not write code."
Let an agent do it: an agent can produce the README's pipeline section from ci.yml. You still check that it describes what the file does, not what pipelines usually do: compare each sentence with a job or a step.
Recap (10 min)
Count your score. The missed topics come back on day 55, when the final pipeline is built.
Sources
- Martin Fowler — Continuous Integration and continuousdelivery.com (Jez Humble)
- GitHub Actions documentation and Forgejo Actions user guide
- GitHub advisory GHSA-mrrh-fwg8-r2c3 — tj-actions/changed-files (CVE-2025-30066)
- Aqua Security advisory GHSA-69fq-xp46-6x23 — Trivy ecosystem compromise (CVE-2026-33634)
- pytest and Ruff
- Docker documentation, Dockerfile best practices, Compose Specification
- Open Container Initiative
Found this useful? Tell us what to measure next.