You will build an end-to-end pipeline that tests, builds a container, scans it, and deploys on a successful merge to main. By the end every release is automated, gated, and traceable to a commit.

This lab chains together everything from the earlier GitHub Actions and Docker labs into a single production-style workflow. A commit or pull request triggers the test stage; only green tests build a container image; the image is pushed to a registry and scanned for critical vulnerabilities; and only a clean scan on the main branch is allowed to roll out to the cluster. Each stage passes or fails the whole run, so a broken build or a vulnerable image can never reach production silently.

The pattern you build here is the same one used in real teams: fast feedback for developers (tests in under a minute), a supply-chain gate (vulnerability scanning), and a controlled blast radius (deploy only from the protected default branch). Everything is defined as YAML in the repo, so the pipeline itself is versioned, reviewed, and auditable like any other code.

Learning Objectives

  • Chain test, build, scan, and deploy stages.
  • Gate deployment on passing tests and a clean scan.
  • Deploy a container to a target on merge to main.
  • Authenticate jobs to a container registry and a Kubernetes cluster using GitHub secrets.
  • Tag images with the commit SHA so every running pod maps to exactly one commit.
  • Time: ~5 hours · Difficulty: Advanced · Prereqs: the GitHub Actions and Docker labs.

Architecture Overview

The pipeline is a directed chain of jobs. Each job runs in its own runner and only starts when the previous job succeeds, because of the needs: keyword. The scan is the security gate: a critical finding flips the run to failed and the deploy job never starts.

Two conditions guard the deploy stage, and both must be true: the scan job must have passed (enforced by needs: scan) and the workflow must be running against the main branch (enforced by an if: condition). Pull requests run the test, build, and scan stages only, so reviewers see security feedback before merge without any risk of a preview branch reaching production. On pull requests the registry push is skipped (fork tokens are read-only) and the built image reaches the scan job as a workflow artifact, so the same immutable artifact is still what gets scanned.

How It Works

How It Works

A CI/CD pipeline is a chain of gates: each stage (test, build, scan, deploy) must pass before the next runs. Failing fast at the test stage saves the cost of building and scanning code that was already broken.

Image scanning with Trivy checks the built image against vulnerability databases before it can deploy. Failing on critical CVEs makes security a release gate rather than a post-incident report, and the scan runs on the exact artifact that would ship.

Deploying on merge to main with the commit-SHA tag closes the traceability loop: the running container's tag names the commit, the pipeline run links the commit to the scan results, and rollback is just redeploying the previous tag.

Environment Setup

Prerequisites

  • A GitHub repository with Actions enabled and a Dockerfile to build
  • Container registry access (ghcr.io used here) with credentials in GitHub secrets
  • Trivy for image scanning (as an action or CLI)
  • A deploy target: kubectl context or SSH access, credentials as secrets
  • Assumed skill: GitHub Actions basics and Docker fundamentals

You will need: GitHub Actions, a container registry, Trivy, and a deploy target (kubectl/SSH).

Before you begin: store registry and deploy credentials as GitHub secrets.

Concretely, this lab uses GitHub Container Registry (ghcr.io) as the registry and a Kubernetes deployment named app as the target. Install the CLI tools locally so you can verify every stage from your terminal as well as in the Actions UI:

INSTALL THE CLI TOOLS (macOS)

brew install gh trivy kubectl
gh auth login        # authenticate the GitHub CLI
kubectl version --client
trivy version

Secrets must never live in the workflow file. Create them once with the GitHub CLI (or under repo Settings › Secrets and variables › Actions) and reference them as ${{ secrets.NAME }}. The pipeline needs exactly one secret: a kubeconfig that can update the deployment. Registry pushes use the built-in GITHUB_TOKEN with a job-level packages: write permission, so no long-lived personal access token is created or stored anywhere.

STORE THE PIPELINE SECRETS

STORE THE KUBECONFIG SECRET (BASE64-ENCODED)

# Base64 keeps the multiline kubeconfig intact through the secrets transport;
# the workflow decodes it with: echo "$SECRET" | base64 --decode
base64 -w0 ~/.kube/config | gh secret set KUBE_CONFIG_B64

gh secret list

Expected output of the last command:

KUBE_CONFIG_B64  Updated 2026-09-19

The repository layout is deliberately small, the pipeline, the app, its tests, and its Dockerfile all live side by side:

repo/
├── .github/workflows/cicd.yml   # the pipeline you are building
├── app.py                       # demo application
├── requirements.txt
├── tests/test_app.py            # pytest suite
└── Dockerfile                   # from the Docker Images lab

Common mistake: putting the workflow in .github/workflow/ (singular) or only on a feature branch, Actions only discovers workflows in .github/workflows/ on the default branch for push triggers to behave as expected.

Step-by-Step Execution

01
Test then build a tagged image

The first two jobs form the quality gate. The test job installs dependencies and runs the test suite; the build job declares needs: test, so a failing test stops the run before a single layer of the image is built. The image is tagged with ${{ github.sha }}, the full commit SHA, so the artifact running in production later can be traced back to one exact commit, with no ambiguity about what "latest" meant at deploy time.

TEST JOB, .github/workflows/cicd.yml

name: cicd
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read   # every job adds only the extra scopes it needs

concurrency:
  group: cicd-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install -r requirements.txt
      - run: pytest -q

The build job below logs in to ghcr.io with the built-in GITHUB_TOKEN (job-level packages: write, no PAT needed), builds the image, and pushes it on trusted events only. Pull requests from forks receive a read-only token that cannot push to the registry, so the push step is skipped on pull_request events; instead the built image is saved and uploaded as a workflow artifact for the scan job. The image is built exactly once either way.

The original minimal form of this job was:

jobs:
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t ghcr.io/tyfsadik/app:${{ github.sha }} .

FULL BUILD JOB WITH REGISTRY LOGIN AND PUSH

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write   # scoped to this job; fork PRs still get read-only
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - run: docker build -t ghcr.io/tyfsadik/app:${{ github.sha }} .
      # Push only on trusted events: fork PRs receive a read-only token.
      - run: docker push ghcr.io/tyfsadik/app:${{ github.sha }}
        if: github.event_name != 'pull_request'
      # On PRs, hand the built image to the scan job as an artifact instead.
      - run: docker save ghcr.io/tyfsadik/app:${{ github.sha }} -o /tmp/app.tar
        if: github.event_name == 'pull_request'
      - uses: actions/upload-artifact@v4
        if: github.event_name == 'pull_request'
        with:
          name: app-image
          path: /tmp/app.tar
          retention-days: 1

Push a commit and watch the run locally:

$ git push && gh run watch
✓ test Tests 38s ✓ build Build image 1m04s

How to verify: after a push to main, open Packages under your GitHub profile: the app package should list a tag equal to your commit SHA (git rev-parse HEAD). On a pull request there is no push by design; check the run's Artifacts section for app-image instead. Common mistake: omitting packages: write on the build job, which makes the push fail with denied: installation not allowed to Create organization package.

What this teaches: Tagging the image with the commit SHA ties every artifact to the exact source that produced it.

02
Scan and fail on critical CVEs

Building an image is not enough, base images ship with OS packages, and dependencies drift out of date within weeks. Trivy scans the pushed image for known vulnerabilities and the --exit-code 1 flag turns any CRITICAL finding into a failed job, which blocks the deploy stage via the job chain. This is the supply-chain gate of the pipeline.

Run the same command locally first so you understand what the CI step does. Note the substitution: ${{ github.sha }} is Actions-only syntax, so locally you resolve the SHA with git rev-parse HEAD:

trivy image --exit-code 1 --severity CRITICAL "ghcr.io/tyfsadik/app:$(git rev-parse HEAD)"

SCAN JOB, FAILS THE RUN ON CRITICAL CVEs

  scan:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: read   # pull the pushed image on trusted events
    steps:
      # On trusted events the image was pushed; on PRs it arrives as an artifact.
      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: actions/download-artifact@v4
        if: github.event_name == 'pull_request'
        with:
          name: app-image
      - run: docker load -i app-image/app.tar
        if: github.event_name == 'pull_request'
      - uses: aquasecurity/[email protected]
        with:
          image-ref: ghcr.io/tyfsadik/app:${{ github.sha }}
          severity: CRITICAL
          ignore-unfixed: true
          exit-code: '1'

A clean result looks like this:

ghcr.io/tyfsadik/app:9f2c1ab (alpine 3.19.1)
=================================================
Total: 0 (CRITICAL: 0)

...and a blocked one looks like this, with the job exiting non-zero:

┌─────────────┬────────────────┬──────────┬───────────────────┐
│   Library   │ Vulnerability  │ Severity │ Installed Version │
├─────────────┼────────────────┼──────────┼───────────────────┤
│ openssl     │ CVE-2024-0727  │ CRITICAL │ 3.1.4-r0          │
└─────────────┴────────────────┴──────────┴───────────────────┘
Error: Process completed with exit code 1.

How to verify: deliberately pin an old base image (for example FROM python:3.8-slim) on a throwaway branch, open a PR, and confirm the scan job goes red while deploy never runs. Common mistake: an UNAUTHORIZED error that looks like a scan result but is actually an auth error. On trusted events the scan job logs in to pull the pushed image; on PRs it loads the artifact, so no registry auth is needed there. If the login step is missing on a push event, the pull fails.

What this teaches: Failing the pipeline on critical CVEs turns scanning from a report into a gate: vulnerable images cannot ship.

03
Deploy on merge to main

The deploy job is the only stage that touches production, so it carries the strongest guard: if: github.ref == 'refs/heads/main' means it is skipped entirely on pull requests, and needs: scan means a vulnerable image stops the chain before this job is even scheduled. The job writes the KUBE_CONFIG secret to a temporary file, points kubectl at it, updates the deployment to the exact image built in step 01, and waits for the rollout to finish, so a failed rollout fails the pipeline run instead of passing silently. The job also declares a production-deploy concurrency group with cancel-in-progress: false, so two runs can never roll out over each other: a second run waits instead of racing the first.

DEPLOY JOB, GATED ON MAIN AND A CLEAN SCAN

  deploy:
    needs: scan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production-deploy
      cancel-in-progress: false
    permissions:
      contents: read
    steps:
      - uses: azure/setup-kubectl@v4
      - name: Write kubeconfig
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG_B64 }}" | base64 --decode > $HOME/.kube/config
          chmod 600 $HOME/.kube/config
      - name: Roll out new image
        run: |
          kubectl set image deploy/app app=ghcr.io/tyfsadik/app:${{ github.sha }}
          kubectl rollout status deploy/app --timeout=120s

A successful rollout prints:

$ kubectl set image deploy/app app=ghcr.io/tyfsadik/app:$GITHUB_SHA && kubectl rollout status deploy/app
deployment "app" successfully rolled out

How to verify: kubectl get pods -l app=app should show new pods in Running state with a recent AGE, and kubectl describe deploy app | grep Image should show the commit-SHA tag. Common mistake: the rollout hangs at Waiting for deployment "app" rollout to finish because the cluster cannot pull from the private registry, fix it by creating an image pull secret. Export a token with read:packages scope first (a fine-grained PAT works; the ephemeral GITHUB_TOKEN expires with the workflow run, so the cluster needs its own long-lived credential): kubectl create secret docker-registry ghcr-creds --docker-server=ghcr.io --docker-username=tyfsadik --docker-password="$GHCR_TOKEN", then reference it under imagePullSecrets in the deployment manifest.

What this teaches: Gating deployment on passing tests plus a clean scan plus the main branch is what makes releases boring and safe.

Complete Workflow File

The three steps above built the pipeline job by job. Here is the entire .github/workflows/cicd.yml assembled, so you can copy it in one piece and then read back through the annotations below. Note that the scan job also gets its own registry login, Trivy pulls the image from ghcr.io, and a private package requires authentication just like a push does.

FULL PIPELINE, .github/workflows/cicd.yml

name: cicd
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read   # every job adds only the extra scopes it needs

concurrency:
  group: cicd-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install -r requirements.txt
      - run: pytest -q

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write   # scoped to this job; fork PRs still get read-only
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - run: docker build -t ghcr.io/tyfsadik/app:${{ github.sha }} .
      # Push only on trusted events: fork PRs receive a read-only token.
      - run: docker push ghcr.io/tyfsadik/app:${{ github.sha }}
        if: github.event_name != 'pull_request'
      # On PRs, hand the built image to the scan job as an artifact instead.
      - run: docker save ghcr.io/tyfsadik/app:${{ github.sha }} -o /tmp/app.tar
        if: github.event_name == 'pull_request'
      - uses: actions/upload-artifact@v4
        if: github.event_name == 'pull_request'
        with:
          name: app-image
          path: /tmp/app.tar
          retention-days: 1

  scan:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: read   # pull the pushed image on trusted events
    steps:
      # On trusted events the image was pushed; on PRs it arrives as an artifact.
      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: actions/download-artifact@v4
        if: github.event_name == 'pull_request'
        with:
          name: app-image
      - run: docker load -i app-image/app.tar
        if: github.event_name == 'pull_request'
      - uses: aquasecurity/[email protected]
        with:
          image-ref: ghcr.io/tyfsadik/app:${{ github.sha }}
          severity: CRITICAL
          ignore-unfixed: true
          exit-code: '1'

  deploy:
    needs: scan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production-deploy
      cancel-in-progress: false
    permissions:
      contents: read
    steps:
      - uses: azure/setup-kubectl@v4
      - name: Write kubeconfig
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG_B64 }}" | base64 --decode > $HOME/.kube/config
          chmod 600 $HOME/.kube/config
      - name: Roll out new image
        run: |
          kubectl set image deploy/app app=ghcr.io/tyfsadik/app:${{ github.sha }}
          kubectl rollout status deploy/app --timeout=120s

A few lines deserve a second look before you commit this:

  • on: push: branches: [main] plus pull_request: means PRs run the full test/build/scan chain for review feedback, while only merges to main reach the deploy job.
  • The top-level permissions: block grants only contents: read. The build job adds packages: write and the scan job adds packages: read; no other job can touch the registry, so a compromised step holds the smallest possible token.
  • The push is skipped on pull_request events because fork PRs receive a read-only token. The image is still built exactly once per run; on PRs it travels to the scan job as an artifact instead of through the registry.
  • needs: appears on every job after test, which is what turns four independent jobs into a single gated chain. Remove one and that stage runs in parallel, breaking the gate.
  • The same ${{ github.sha }} tag appears in build, scan, and deploy, one immutable artifact flows through the whole pipeline instead of being rebuilt at each stage.
  • concurrency cancels superseded runs on the same ref, and the deploy job's production-deploy group (with cancel-in-progress: false) serializes production rollouts so two runs never race.
  • The kubeconfig travels base64-encoded (KUBE_CONFIG_B64) and is decoded with base64 --decode, so multiline transport cannot mangle it the way a raw echo of the secret can.
  • environment: production attaches the deploy job to a GitHub environment, which is where you later add required reviewers for a manual approval gate.

Validate the YAML before pushing, a syntax error in a workflow file fails silently until the next trigger, which is a frustrating way to discover a typo:

SANITY-CHECK THE WORKFLOW LOCALLY

python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/cicd.yml')); print('workflow YAML OK')"
# workflow YAML OK

Progress So Far

All three stages are now wired together: tests gate the build, the build feeds the scan, and the scan gates the deploy. The remaining work is proving the whole chain behaves correctly, including its failure modes.

Testing & Validation

gh run list --workflow=cicd.yml --limit 1 && kubectl get deploy app -o jsonpath='{.spec.template.spec.containers[0].image}'

The latest run should be green and the deployment image tag should match the merged commit SHA, proving end-to-end traceability.

Validate the pipeline in both directions, the happy path and the gates. First confirm the happy path end to end:

HAPPY-PATH VERIFICATION

git rev-parse HEAD
# 9f2c1ab4e7d...  (compare with the image tag below)

gh run list --workflow=cicd.yml --limit 1
# cicd  main  push  9f2c1ab  completed  success  3m41s

kubectl get deploy app -o jsonpath='{.spec.template.spec.containers[0].image}'
# ghcr.io/tyfsadik/app:9f2c1ab4e7d...

Then prove the gates actually block bad releases, a gate you have never seen fire is a gate you cannot trust:

  • Test gate: break a test on a branch, push, and confirm the run fails at the test job and no image is built or pushed.
  • Scan gate: pin a vulnerable base image on a branch and confirm the scan job fails with exit code 1 and the deploy job shows as skipped.
  • Branch gate: confirm that on the same PR, the deploy job is skipped because the ref is not refs/heads/main.
  • Rollback path: run kubectl rollout undo deploy/app once in a test environment so you know the recovery command works before you need it.
Troubleshooting
  • Deploy runs on every branch: guard the deploy job with if: github.ref == 'refs/heads/main'.
  • Registry push denied: for ghcr.io prefer the built-in GITHUB_TOKEN with packages: write on the build job; a classic PAT needs the write:packages scope. On fork pull requests the token is read-only by design, which is why this workflow skips the push on pull_request events instead of failing.
  • Scan blocks valid release: triage the CVE; only fail on fixable criticals where appropriate. Add ignore-unfixed: true so vulnerabilities with no available patch do not freeze releases, and document any accepted risk in a .trivyignore file with an expiry comment.
  • Job stuck at "Waiting for a runner": the runs-on label is wrong (for example ubuntu-latests) or the repo has Actions disabled under Settings › Actions. Fix the label and re-run with gh run rerun.
  • kubectl: command not found in the deploy job: runners do not ship with kubectl preconfigured for your context, add azure/setup-kubectl@v4 (or aws-actions/configure-aws-credentials plus aws eks update-kubeconfig on EKS) before calling it.
  • Rollout times out after deploy: the new pods are crash-looping or the image cannot be pulled. Inspect with kubectl get pods and kubectl describe pod <name>; the pipeline will fail the run after --timeout=120s, which is the correct behaviour, fix the cause, then re-run.
  • Image tag mismatch between build and deploy: always thread the tag through ${{ github.sha }} in every job. Never use latest, it makes rollbacks and incident forensics guesswork.

Troubleshooting

SymptomLikely causeFix
docker build fails in CI but works locallydifferent build context or missing .dockerignore entriescheck which files the action uploads; add .dockerignore to keep the context small
Trivy flags criticals on a fresh base imagethe base image itself has known CVEspin a patched base image tag and rebuild; document exceptions deliberately
deploy step cannot authenticatesecret misnamed or kubeconfig malformedverify the secret is named KUBE_CONFIG_B64 and holds base64 output (decode it locally to check); re-run the base64 secret-set command to rotate
pipeline deploys on every branch pushthe deploy job lacks a branch conditiongate it with if: github.ref == 'refs/heads/main'
rollback is slowprevious image tags were overwritten by latestnever overwrite release tags; keep immutable SHA tags for every build

Extension Ideas

  • Add a manual approval gate for production: configure the production environment in repo Settings with required reviewers, so the deploy job pauses until a human approves.
  • Deploy to AKS instead of a single cluster, using a federated workload identity instead of a stored kubeconfig.
  • Add IaC apply with Terraform in the pipeline so infrastructure changes ship through the same gated workflow.
  • Sign images with cosign and add a verification admission policy, extending the supply-chain gate from "scanned" to "provenance-verified".

Practice Challenges

Challenge 1: Staging gate

Goal: Add a manual-approval environment so deploys to production wait for a human after staging passes.

Hint: Use GitHub environments with required reviewers on the production environment.

Challenge 2: SBOM attach

Goal: Generate an SBOM with Trivy and attach it to the GitHub release for every deployed tag.

Hint: Upload the SBOM as a release asset in the same workflow that deploys.

Key Results

  • Automated test, build, scan, and deploy from one commit.
  • Blocked releases with critical CVEs before deploy.
  • Tagged images with the commit SHA for full traceability.
  • Deployed only from main, gated on green checks.

Key Takeaways

  • needs: turns separate jobs into a chain: each stage is a gate that can stop everything downstream.
  • Every credential lives in GitHub secrets; the workflow file itself stays safe to read in the repo.
  • Tag images with the commit SHA, never latest, so production state always maps to one exact commit.
  • Deploy requires both a clean scan and the main ref: defense in depth, not a single check.
  • Test the gates by deliberately failing them; an untested gate is an assumption.