Skip to content

Phase 2 · CONTAINERS & CI/CD

Week 5 review — harden your pipeline

Day 35 of 90 ~45 min 0/25 in phase Builds on Day 34

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

Section 1 of 5 · ~3 min

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, inspect and stats are 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-action building 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:

  1. Pin actions. actions/checkout@v4 is a moving tag; pin to a full commit SHA so a compromised action can’t swap code under you.
  2. Least-privilege GITHUB_TOKEN. The default token can be broad; add a permissions: block scoped to contents: read, plus packages: write only where you push.
  3. Cache. Reuse dependency and layer caches so a rebuild is seconds, not minutes — fast pipelines get run, slow ones get skipped.
  4. Fail-fast. Order the cheap checks (lint, unit tests) before the expensive build, so a broken commit dies in ten seconds, not ten minutes.
  5. Scan. Run docker scout or 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

Section 2 of 5 · ~4 min

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

Section 3 of 5 · ~3 min

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_denied

Why: GITHUB_TOKEN only has the scopes the workflow grants it. If the permissions: block sets contents: read but never adds packages: 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: write to the workflow’s permissions: block (top-level, or on the job that pushes). Keep everything else at read so 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 the permissions: block before you touch credentials or the registry.

Common error: A step’s command exits non-zero — here ruff finds 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 1 is 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 test job 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 1 and 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 (or gh 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

Section 4 of 5 · ~1 min

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

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 5 min — Add .github/dependabot.yml with package-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