Phase 2 · CONTAINERS & CI/CD
Week 5 review — harden your pipeline
By the end of today
- Recap Week 5: image hygiene, container debugging, CI/CD and GitHub Actions
- Harden a workflow: pin actions, scope GITHUB_TOKEN, cache, fail-fast, scan
- Answer the highest-yield CI/CD and GitHub Actions interview questions calmly
Week 5 in one reflex: a pipeline you can trust
Week 5 took you from “I can run a container” to “a robot builds, checks and ships my image without me.” Days 29–34 stack into one arc:
- Day 29 — image hygiene. Multi-stage builds leave the compilers behind, a slim base cuts hundreds of megabytes, and a smaller image is a smaller attack surface.
- Day 30 — container debugging.
docker logs,exec -it,inspectandstatsare how you read a container that misbehaves instead of guessing at it. - Day 31 — CI/CD concepts. A pipeline is stages — build, test, deploy — with artifacts flowing between them and environments gating the risky end. (Jenkins still runs plenty of them; you learned Actions.)
- Days 32–34 — GitHub Actions. Workflow anatomy and triggers, then jobs, matrices and secrets, then the payoff:
docker/build-push-actionbuilding your image and pushing it to GHCR.
You can now write a workflow that turns a git push into a published image. Today asks the harder question: can you trust it? A pipeline runs with credentials, pulls third-party code, and ships straight toward production — an unhardened one is the softest target in your whole stack.
Five moves that harden a pipeline
Hardening is a checklist, and every item is one line of YAML or one command:
- Pin actions.
actions/checkout@v4is a moving tag; pin to a full commit SHA so a compromised action can’t swap code under you. - Least-privilege
GITHUB_TOKEN. The default token can be broad; add apermissions:block scoped tocontents: read, pluspackages: writeonly where you push. - Cache. Reuse dependency and layer caches so a rebuild is seconds, not minutes — fast pipelines get run, slow ones get skipped.
- Fail-fast. Order the cheap checks (lint, unit tests) before the expensive build, so a broken commit dies in ten seconds, not ten minutes.
- Scan. Run
docker scoutor Trivy on the built image and fail the job on a critical CVE before it ever reaches the registry.
Real world: A hardened pipeline is a commercial kitchen’s pass. Every dish crosses one lit counter where the head chef checks it before it leaves — wrong plate, it goes back. Skip the pass to save time and the mistake reaches the table. The pass doesn’t slow a good kitchen down; it’s exactly why the kitchen can move fast without ever sending out a bad plate.
A named example makes the stakes real. In March 2025 the popular tj-actions/changed-files action was compromised: an attacker rewrote its tags to dump CI secrets into build logs across thousands of repositories. Every project that referenced it by a moving tag (@v35) pulled the malicious code automatically; the projects that had pinned to a specific commit SHA were untouched. That single line — a SHA instead of a tag — was the difference between a leaked secret and a normal build.
push → [checkout @SHA] → [lint + unit tests] --fail-fast--> ✗ stop
│ pass
▼
[build image] → [scan: scout/Trivy] --critical CVE--> ✗ stop
│ pass
▼
[push to ghcr.io] (GITHUB_TOKEN: contents read, packages write)
Today is a drill, not a lecture — you’ll harden a real workflow line by line below, then drill the CI/CD and Actions questions an interviewer actually asks cold.
Hands-On Lab
Budget about 20 minutes. Open your WSL2 Ubuntu 24.04 terminal with Docker 27+ and the gh CLI, on the app repo you built and pushed in Days 32–34. This is a mixed Week-5 drill: two quick recall checks, then you harden the build-and-push workflow one move at a time. Run IDs, SHAs, digests, sizes and timestamps are unique to each run — yours will differ from the samples.
# 1. Day 29 recap — image hygiene. A slim, multi-stage image stays small; check yours.
docker images myapp
# Output (IMAGE ID and SIZE are yours-will-differ — the point is it's tens of MB, not a GB):
# REPOSITORY TAG IMAGE ID CREATED SIZE
# myapp 1 3f9a1c8be2d1 2 hours ago 58MB
# 2. Day 30 recap — debugging. Run a container, then read its last log line and live usage.
docker run -d --name web nginx:1.27-alpine
docker logs web | tail -n 1
docker stats --no-stream web
# Output (CPU/MEM values are yours-will-differ; the worker/PID counts scale with CPU count, so yours will differ too):
# 2026/07/11 09:14:22 [notice] 29#29: start worker process 29
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
# a1b2c3d4e5f6 web 0.00% 3.2MiB / 7.6GiB 0.04% 1.1kB/0B 0B/0B 2
docker rm -f web
# 3. Harden move 1 — pin actions. Look up the immutable commit SHA behind a release tag.
gh api repos/actions/checkout/tags --jq '.[] | select(.name=="v4.2.2") | .commit.sha'
# Output (the SHA you paste into `uses:` — verify the current release yourself, yours may differ):
# 11bd71901bbe5b1630ceea73d27597364c9af683
# 4. Rewrite .github/workflows/build.yml hardened: pinned SHAs, scoped token, cache, fail-fast.
name: build
on:
push:
branches: [main]
# Least-privilege: the token may read the repo and write packages (GHCR) — nothing else.
permissions:
contents: read
packages: write
jobs:
test: # fail-fast — cheap checks gate the expensive build
runs-on: ubuntu-24.04
steps:
# Pinned to a full commit SHA, not the moving @v4 tag (comment records the version).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: |
pip install ruff
ruff check .
build:
needs: test # only runs if `test` passed
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true
tags: ghcr.io/pushkar/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 5. Commit the hardened workflow and push — the push event triggers the run.
git add .github/workflows/build.yml
git commit -m "ci: harden build workflow (pin, scope token, cache, fail-fast)"
git push
# Output (branch and commit hashes are yours-will-differ):
# [main 9c1d2e3] ci: harden build workflow (pin, scope token, cache, fail-fast)
# 1 file changed, 34 insertions(+), 12 deletions(-)
# To github.com:pushkar/myapp.git
# e380f95..9c1d2e3 main -> main
# 6. Watch the run live. `test` must pass before `build` starts — that is fail-fast working.
gh run watch
# Output (run/job IDs and timings are yours-will-differ):
# ✓ main build · 12345678901
# Triggered via push about 20 seconds ago
#
# JOBS
# ✓ test in 14s (ID 34567890123)
# ✓ build in 41s (ID 34567890456)
#
# ✓ Run build (12345678901) completed with 'success'
# 7. Confirm the image reached GHCR, tagged with the commit SHA (immutable, not :latest).
docker pull ghcr.io/pushkar/myapp:$(git rev-parse HEAD)
# Output (the digest is content-addressed — yours will differ):
# <sha>: Pulling from pushkar/myapp
# Digest: sha256:… (unique to this build — never hard-code it)
# Status: Downloaded newer image for ghcr.io/pushkar/myapp:<sha>
# 8. Harden move 5 — scan the built image for critical CVEs before you trust it.
docker scout cves ghcr.io/pushkar/myapp:$(git rev-parse HEAD) --only-severity critical
# Output (package counts and findings change as advisories land — yours will differ):
# ✓ Image stored for indexing
# ✓ Indexed 47 packages
# ✓ No critical vulnerabilities found
Read the run back as one sentence: a push ran the cheap tests first, only then built the image with the layer cache warm, pinned every third-party action to a reviewed SHA, pushed to GHCR under a token that can do nothing but read code and write packages, and scanned the result before you trusted it. That is a pipeline you can leave running unattended — which is the whole point of Phase 2.
Common Errors & Fixes
These three are the ones that bite the moment a workflow goes from “runs” to “runs safely.” Read the error text slowly — parsing it is the actual skill.
Common error: Pushing an image to GHCR from a workflow whose token was never granted package write:
ERROR: failed to push ghcr.io/pushkar/myapp:9c1d2e3f8a4b6c7d8e9f0a1b2c3d4e5f60718293: denied: permission_denied: write_package Error: buildx failed with: ERROR: failed to solve: failed to push ...: denied: permission_deniedWhy:
GITHUB_TOKENonly has the scopes the workflow grants it. If thepermissions:block setscontents: readbut never addspackages: write(or the repo default is read-only), the login succeeds but the push is refused — the token is authenticated, just not authorized to write a package.Fix: Add
packages: writeto the workflow’spermissions:block (top-level, or on the job that pushes). Keep everything else atreadso you widen the token by exactly one scope, not by making it broad.How you’d spot it in prod: A build that goes green through test and build, then fails only at the push step with
permission_denied, is almost always a token-scope problem, not a bad Dockerfile. Check thepermissions:block before you touch credentials or the registry.
Common error: A step’s command exits non-zero — here
rufffinds a lint violation — and GitHub Actions stops the job:app.py:12:1: F401 [*] `os` imported but unused Found 1 error. Error: Process completed with exit code 1.Why: Every
run:step is a shell command, and Actions treats a non-zero exit code as failure.Process completed with exit code 1is not an Actions bug — it’s the step’s own program (ruff, a test runner, a build) reporting that it failed. The real message is the line above it; the exit-code line is just Actions relaying the verdict.Fix: Read the line above the exit-code line and fix the actual failure — remove the unused import, fix the failing test. This is fail-fast doing its job: the cheap
testjob caught the problem in seconds and never let the expensive build or push run.How you’d spot it in prod: A red job with
exit code 1and no other clue means someone is reading the wrong line — scroll up to the last command’s output. In a well-ordered pipeline this failure is a feature: the broken commit stopped at the gate instead of shipping.
Common error: A YAML indentation slip in the workflow file, so GitHub can’t parse it at all:
Invalid workflow file: .github/workflows/build.yml#L11 The workflow is not valid. .github/workflows/build.yml (Line: 11, Col: 5): Unexpected value 'steps'Why: YAML is whitespace-significant, and Actions parses the file before it runs anything. If
steps:is indented one level too shallow it’s read as a sibling of the job rather than a key inside it, so the parser rejects the whole file — the workflow never starts, and the Actions tab shows a red “Invalid workflow file” banner instead of a run.Fix: Line up the indentation:
steps:sits under the job, its list items two spaces further in. Run the file through a YAML linter (orgh workflow view) before pushing, and never mix tabs and spaces — YAML forbids tabs for indentation.How you’d spot it in prod: A push that produces no run at all — not a failed one — usually means an invalid workflow file, not a broken pipeline. Check the Actions tab for the “Invalid workflow file” banner and the exact line and column it names.
CI/CD & GitHub Actions Interview Questions
These four are the highest-yield questions a DevOps screening round asks about a pipeline you can actually trust — pinning, token scope, speed, and scanning, one from each corner of the week. Cover each answer, say your own version out loud first, then compare — recalling before revealing is what makes it stick for interview day. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Add
.github/dependabot.ymlwithpackage-ecosystem: "github-actions"so Dependabot opens a PR whenever a pinned action ships a new release — you keep the safety of SHAs without the staleness. - 10 min — Read GitHub’s Security hardening for GitHub Actions and map each recommendation onto the five moves you drilled today.
- 15 min — Reread the registries-and-scanning section of the Docker for DevOps guide so pushing to GHCR and failing a build on a CVE settle into the wider container workflow you finish this phase on.
How do you pin a third-party GitHub Action, and why not just use a version tag? Both
Pin to a full commit SHA — actions/checkout@11bd719… — instead of a moving tag like @v4. A tag is a pointer the action's maintainer can repoint at any time; a SHA names one immutable commit. That matters because a workflow runs third-party code with access to your repo and secrets: if a popular action is compromised and its tag rewritten, every repo on that tag pulls the malicious code automatically. That's exactly what happened with tj-actions in 2025. Pinning to a SHA means you run only the code you reviewed, and you update deliberately by bumping the SHA — ideally with Dependabot watching for new releases. Tags are convenient; SHAs are safe.
What does the permissions block do for GITHUB_TOKEN, and why set it? Both
Every workflow gets an automatic GITHUB_TOKEN to talk to the GitHub API. By default it can be broad, so a compromised step could push code or packages you never intended. A top-level permissions block scopes it down — I set contents: read as the baseline and add a narrow grant like packages: write only on the job that pushes to GHCR. It's least privilege applied to CI: the token can do exactly what the pipeline needs and nothing more, so a hijacked action has a tiny blast radius. I always set it explicitly rather than trusting repo defaults, because those vary between repositories and an unset block is an easy thing to forget.
How do you make a CI pipeline faster and fail sooner? Both
Two levers. First, fail-fast ordering: run the cheap checks — lint, unit tests — as a job the expensive build needs, so a broken commit dies in seconds instead of after a five-minute image build. Second, caching: cache dependencies and Docker layers with cache-from and cache-to: type=gha, so a rebuild reuses everything that didn't change. A fast pipeline is one people actually keep in the loop; a slow one gets bypassed with 'I'll just push straight to main.' I also keep independent jobs parallel and only serialize with needs where there's a real dependency. Speed and early failure aren't polish — they're what makes the pipeline trustworthy.
Where does image scanning fit in a pipeline, and what do you use? Product
I scan the built image for known CVEs before it's pushed to the registry, and fail the job on a critical finding — a vulnerable image should never become the artifact a deploy pulls. The current tools are docker scout, built into Docker, and Trivy; both index the image's packages against advisory databases. Scanning after build but before push means a bad image stops in CI, not in production. I pair it with the image hygiene from earlier in the week — a slim, multi-stage image has fewer packages, so fewer things that can be vulnerable. Scanning isn't a one-off audit; it runs on every build, because new CVEs land against images that were clean yesterday.
Mark Day 35 complete
Tomorrow you add tests to the pipeline — lint, unit tests and quality gates that block a broken build before it ever ships.
Stuck on today’s lab? Ask in Mission 90 Q&A