Module 6 — CI/CD with GitHub Actions & a Self-Hosted Runner on Podman
Goal: git fluency, a real pipeline (lint → test → build → deploy) in GitHub Actions, and a self-hosted runner running in a Podman container on lab-box so the student sees exactly where "the magic" executes.
6.1 Git primer (skip/compress if student scored on Q12)
Concepts: repo, commit (a snapshot with a message), branch, merge, remote, pull request. Git is change control you already believe in — every commit is a signed-off MOP entry, every PR is a peer-reviewed change window.
Lab 6.1
git config --global user.name "Student Name"
git config --global user.email "student@example.com"
mkdir ~/labs/module6/fieldapp && cd $_ && git init
# copy in module 5's server.py + Containerfile
git add . && git commit -m "Initial app"
git log --oneline
git switch -c feature/version-2
sed -i 's/APP_VERSION=1/APP_VERSION=2/' Containerfile
git diff
git commit -am "Bump to v2"
git switch main && git merge feature/version-2
Then create a GitHub account/repo, add SSH key, git remote add origin …,
git push -u origin main. From now on all lab work is pushed.
6.2 CI/CD concepts
- CI — every push is automatically built and tested; broken changes are caught in minutes, not in production.
- CD — releases become boring: the pipeline deploys what passed CI, the same way, every time. Deploy frequency goes up because risk per deploy goes down.
- Pipeline anatomy: trigger (push/PR/schedule) → jobs (run on runners) → steps (commands/actions). Artifacts flow between stages.
- The telecoms frame: CI/CD is an automated change-control process — the MOP is
code, the sign-off is a green tick, the rollback is
git revert.
6.3 First workflow (GitHub-hosted runner)
.github/workflows/ci.yml in the fieldapp repo:
name: ci
on:
push: { branches: [main] }
pull_request:
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Lint
run: |
pip install ruff
ruff check .
- name: Smoke test
run: |
python server.py & sleep 1
curl -sf localhost:8000 | grep -q Hello
build-image:
needs: lint-test
runs-on: ubuntu-latest
permissions: { packages: write, contents: read }
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build & push
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
Push it; watch the run live in the Actions tab. Then break it on purpose (add a lint error), open a PR, and watch the red X block the merge. Fix, green tick, merge. That loop is the whole point of CI.
6.4 Self-hosted runner in Podman — see where the magic runs
Why self-host: deploy into private networks (like our VPC), custom tooling, cost at scale — and pedagogically, it makes the runner visible.
Lab 6.4 — Runner container on lab-box
- Repo → Settings → Actions → Runners → "New self-hosted runner" → copy the registration token.
- On lab-box, build a minimal runner image —
~/labs/module6/runner/Containerfile:
FROM docker.io/library/ubuntu:24.04
RUN apt-get update && apt-get install -y curl jq git sudo libicu74 && \
useradd -m runner && rm -rf /var/lib/apt/lists/*
USER runner
WORKDIR /home/runner
RUN RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name[1:]') && \
curl -oL runner.tgz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz && \
tar xzf runner.tgz && rm runner.tgz
ENTRYPOINT ["./bin/Runner.Listener"]
- Configure and run (config baked into a volume so re-registration isn't needed every start):
podman build -t gh-runner .
podman volume create runner-data
podman run -it --rm -v runner-data:/home/runner gh-runner \
bash -c './config.sh --url https://github.com/<user>/fieldapp --token <TOKEN> --name labbox-podman --labels labbox --unattended'
podman run -d --name runner --restart=always -v runner-data:/home/runner gh-runner
podman logs -f runner # "Listening for Jobs"
- Add a job that must run on it:
deploy-labbox:
needs: build-image
runs-on: [self-hosted, labbox]
steps:
- name: Deploy new version on lab-box
run: |
podman pull ghcr.io/${{ github.repository }}:${{ github.sha }}
podman rm -f fieldapp || true
podman run -d --name fieldapp -p 8000:8000 ghcr.io/${{ github.repository }}:${{ github.sha }}
curl -sf localhost:8000
Note: for the deploy step to drive the host's podman, the simplest teaching setup is to run this job's runner directly on lab-box (outside a container), or mount the host's podman socket into the runner container (
-v /run/user/1000/podman/podman.sock:/run/podman/podman.sockandCONTAINER_HOST=unix:///run/podman/podman.sock). Do the socket version as a stretch exercise and discuss the security trade-off it introduces — this exact question ("who can reach the container engine?") is a real DevSecOps interview topic.
- Push a change to
server.py(change the greeting). Watch: push → lint/test → image build → deploy lands on lab-box →curlfrom your laptop shows the new greeting. The student has built a complete CD loop.
6.5 Pipeline hygiene & concepts to name
- Secrets management (GitHub Secrets; never in code; OIDC to AWS > long-lived keys).
- Environments and approvals (require a human click before "prod").
- Rollback strategies: redeploy previous SHA; blue/green; canary (concept only — revisit with SLOs in module 7).
- Terraform in CI:
fmt/validate/planon PR,applyon merge — connect to module 4.5, and show the instructor's real pipeline for the production PWA (build → S3 sync → CloudFront invalidation) as a worked example.