Skip to content

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

  1. Repo → Settings → Actions → Runners → "New self-hosted runner" → copy the registration token.
  2. 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"]
  1. 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"
  1. 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.sock and CONTAINER_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.

  1. Push a change to server.py (change the greeting). Watch: push → lint/test → image build → deploy lands on lab-boxcurl from 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/plan on PR, apply on 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.

Quiz

1. What's the difference between CI and CD? CI automatically builds/tests every change; CD automatically (and identically) releases changes that pass. CI catches breakage; CD makes releases routine.
2. Where does a GitHub-hosted job execute? A self-hosted one? GitHub-hosted: a fresh ephemeral VM in GitHub's cloud. Self-hosted: on your machine — here, a Podman container on lab-box that polls GitHub for jobs.
3. Why is deploying "the SHA that passed CI" safer than deploying "latest"? It's immutable and traceable — you deploy exactly the tested artifact, and rollback is just redeploying a previous known-good SHA.
4. Why prefer OIDC over storing AWS access keys in GitHub Secrets? Short-lived, scoped credentials issued per-run; nothing long-lived to leak or rotate.