100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
CI/CD with GitHub Actions
50 minintermediate

Artifacts Practice — Publish a Container Image

What You'll Build

You will create a complete container build and publish pipeline for CricketPulse. The workflow builds a multi-stage Docker image, scans it for vulnerabilities using Trivy, pushes it to GitHub Container Registry with auto-generated semantic tags, and verifies the pushed image is pullable. You will write the Dockerfile, the workflow YAML, and run the pipeline against the CricketPulse repository from earlier exercises. The exercise includes a deliberate base image vulnerability scenario — you will observe how Trivy detects CVEs in an outdated base image and fix it by upgrading, experiencing the full container security feedback loop that production teams run on every commit.

Analogy🏏Cricket
🏏 Think of it like cricket: This exercise is building the complete match management infrastructure: the pitch is prepared (dev), the warm-up match is played (staging approval), and only after the selectors sign off does the Test match begin (production). You'll experience being both the deployer (pushing the commit that initiates the pipeline) and the approver (clicking the approval button in GitHub's reviewer interface) — understanding both sides of the gate is essential for designing gates that are genuinely useful rather than bureaucratic obstacles.

Prerequisites

  • The CricketPulse GitHub repository from previous exercises with the `.github/workflows/` directory.
  • GitHub Container Registry access — automatically available with any GitHub account at `ghcr.io/YOUR_USERNAME`.
  • Docker installed locally to verify the image builds before pushing (optional but recommended).
  • Understanding of multi-stage Dockerfiles, `docker/build-push-action`, and `docker/metadata-action` from Lesson 11.
  • Understanding of GitHub Actions `permissions:` block — the `packages: write` permission is required to push to ghcr.io.

Setup & Project Structure

Create the Dockerfile and a minimal Node.js server for CricketPulse. The server exposes a `/health` endpoint that returns version information embedded at build time. This gives you a verifiable way to confirm the pushed image contains the correct version — after pulling and running the image, hitting `/health` should return the git SHA that triggered the build.

Analogy🏏Cricket
🏏 Think of it like cricket: configuring the three environments and their protection rules in the GitHub UI before writing any workflow is like agreeing the match's playing conditions with the officials before the toss — not mid-innings. Just as environment protection rules live entirely in settings and there is no YAML for them, the tournament's rules on reviews, powerplays, and appeals are fixed by the governing body off the field, not written into a batsman's technique. Just as the workflow simply names an environment and GitHub enforces whatever rules are configured, a player simply takes the field and the umpires enforce the pre-agreed conditions — the player doesn't carry the rulebook, the officials do. The payoff: settle the playing conditions once, in the right place, and every delivery afterwards is automatically governed by them without cluttering the play itself.
bash
# Setup: create the application files and Dockerfile

# Create server entry point
mkdir -p src
cat > src/server.js << 'EOF'
const http = require('http');

const APP_VERSION = process.env.APP_VERSION || 'dev';
const PORT = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      status: 'healthy',
      app: 'CricketPulse API',
      version: APP_VERSION,
      timestamp: new Date().toISOString()
    }));
  } else if (req.url === '/matches') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      matches: [
        { id: 'IPL-2024-001', teams: ['MI', 'CSK'], venue: 'Wankhede', status: 'completed' },
        { id: 'IPL-2024-002', teams: ['RCB', 'KKR'], venue: 'Eden Gardens', status: 'live' }
      ]
    }));
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

server.listen(PORT, () => {
  console.log(`CricketPulse API v${APP_VERSION} listening on port ${PORT}`);
});
EOF

# Create Dockerfile (intentionally using an older Alpine for the vulnerability exercise)
cat > Dockerfile << 'EOF'
# Stage 1: Builder
FROM node:18-alpine3.17 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/

# Stage 2: Production
FROM node:18-alpine3.17 AS production
RUN addgroup -S cricketapp && adduser -S cricketapp -G cricketapp
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
LABEL org.opencontainers.image.version="${APP_VERSION}"
USER cricketapp
EXPOSE 3000
CMD ["node", "src/server.js"]
EOF

# Create .dockerignore
cat > .dockerignore << 'EOF'
node_modules
.git
*.test.js
coverage/
.github/
EOF

echo 'CricketPulse container project setup complete'

Step 1 — Foundation

Create the container workflow with the Buildx setup and registry login steps. This foundation step confirms the Docker toolchain is correctly configured and the GITHUB_TOKEN can authenticate to ghcr.io. A common pitfall is missing the `permissions: packages: write` block — without it, the login step succeeds but the push step fails with a 403 error.

Analogy🏏Cricket
🏏 Think of it like cricket: The warm-up session runs on the hotel practice ground with no spectators, no formal protocols — players take the field and start immediately. The dev environment is the hotel practice ground: automated, immediate, no gatekeeping. Confirming the dev pipeline runs automatically before adding gated environments is like confirming the practice session proceeds smoothly before worrying about the match-day protocols.
yaml
# File: .github/workflows/cricketpulse-container.yml — Step 1

name: CricketPulse Container

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

jobs:
  build-scan-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Verify login
        if: github.event_name != 'pull_request'
        run: echo "Logged in as ${{ github.actor }} to ghcr.io"

Step 2 — Core Logic

Add the metadata generation and container build steps. Build with `load: true` to load the image into the local Docker daemon for scanning — at this stage the image is not pushed to the registry. The metadata step generates all required tags automatically. Building before scanning ensures you're scanning the exact image that would be pushed, not just the source code.

Analogy🏏Cricket
🏏 Think of it like cricket: The warm-up session is complete (dev deployed). Now the match-day protocols begin: the umpires inspect the pitch, the team captains arrive for the toss, the grounds team prepare the outfield. The staging approval is the toss ceremony — a formal, structured pause before the match begins, requiring the participating parties to confirm readiness before play commences.
yaml
# File: .github/workflows/cricketpulse-container.yml — Step 2 (additions)
# Add these steps after the login step:

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}/cricketpulse
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - name: Build container image (local  for scanning)
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true
          tags: cricketpulse:scan-candidate
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: APP_VERSION=${{ github.sha }}

      - name: Verify image locally
        run: |
          docker run --rm -d -p 3000:3000 --name cp-test cricketpulse:scan-candidate
          sleep 2
          curl -f http://localhost:3000/health | python3 -m json.tool
          docker stop cp-test

Step 3 — Integration & Enhancement

Add the Trivy vulnerability scan and conditional push. The scan runs against the locally loaded image and uploads results as a GitHub security advisory SARIF file, which appears in the repository's Security tab. The push only runs if the event is not a pull request AND the scan has not produced critical failures — completing the build → scan → push pipeline.

Analogy🏏Cricket
🏏 Think of it like cricket: After the warm-up (dev) and the pitch inspection ceremony (staging), the Test match finally begins with all the formal protocols: national anthems, player presentations, the coin toss, a final equipment check. Each is a structured delay ensuring everything is ready before the ball is bowled. The 2-minute production wait timer is the Test match's opening ceremony — a deliberate pause giving everyone a final opportunity to abort if something has gone wrong.
yaml
# File: .github/workflows/cricketpulse-container.yml — Step 3 (additions)
# Add after the verify step:

      - name: Scan image for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'cricketpulse:scan-candidate'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

      - name: Upload Trivy scan results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

      - name: Push to GitHub Container Registry
        uses: docker/build-push-action@v5
        if: github.event_name != 'pull_request'
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          build-args: APP_VERSION=${{ github.sha }}

      - name: Output pushed image digest
        if: github.event_name != 'pull_request'
        run: |
          echo "Image pushed successfully"
          echo "Tags: ${{ steps.meta.outputs.tags }}"
          echo "View at: https://github.com/${{ github.repository }}/pkgs/container/cricketpulse"

Step 4 — Testing & Verification

Push the workflow and observe the full pipeline. Then trigger the vulnerability scenario: the older Alpine 3.17 base image likely contains known CVEs. Observe Trivy detect them, then upgrade to `node:20-alpine` (current) and observe the scan pass. Finally, verify the pushed image appears in the repository's Packages tab.

Analogy🏏Cricket
🏏 Think of it like cricket: running the three scenarios — automatic push, manual dispatch with production, and a rejected staging deploy — is like rehearsing how a match unfolds under different calls before the real fixture. Just as an automatic push flows through dev and staging but stops short of production, an ordinary passage of play proceeds freely up to the boundary rope but can't cross into a scoring appeal without review. Just as manual dispatch with production enabled runs the full three-stage pipeline, a captain formally opting to declare unlocks the complete sequence of events that follows. And just as a deliberately rejected staging deployment halts the pipeline, a third umpire ruling not-out on review sends the batsman back and the passage of play simply stops there. Testing all three teaches you how each gate behaves. The payoff: like rehearsing every umpiring outcome in advance, you learn exactly how approvals let a deploy through, how manual triggers open the full path, and how a rejection cleanly stops the release.
bash
# Push and verify
git add Dockerfile src/ .dockerignore .github/workflows/cricketpulse-container.yml
git commit -m 'feat: add container build and publish pipeline'
git push origin main

# Expected pipeline stages:
# 1. build-scan-push job starts
# 2. Docker Buildx setup — ✅
# 3. Registry login — ✅
# 4. Build image locally — ✅ (~2 min first run, ~30s with cache)
# 5. Local health check — ✅ (shows version SHA in JSON response)
# 6. Trivy scan — ⚠️ (may show CVEs in alpine:3.17 base)

# IF Trivy fails due to CVEs in node:18-alpine3.17:
# Fix: update Dockerfile to use node:20-alpine
# sed -i 's/node:18-alpine3.17/node:20-alpine/g' Dockerfile
git add Dockerfile && git commit -m 'fix: upgrade to node:20-alpine to resolve CVEs' && git push

# After successful push, verify the image:
# 1. Go to github.com/YOUR_USERNAME/cricketpulse → Packages tab
# 2. You should see 'cricketpulse' package with tags: latest, main, sha-XXXXXXX

# Pull and run the published image locally:
docker pull ghcr.io/YOUR_USERNAME/cricketpulse:latest
docker run --rm -p 3000:3000 ghcr.io/YOUR_USERNAME/cricketpulse:latest
curl http://localhost:3000/health
# Response should include the git SHA as the version

Warning: If the push step fails with 'denied: installation not allowed to Write organization package', check that the repository's Actions settings allow write access to packages. Go to Settings → Actions → General → Workflow permissions and select 'Read and write permissions'. Alternatively, the repository owner must first manually create the package in GHCR at ghcr.io before Actions can push to it — push once manually with `docker push` to initialise the package namespace.

Extension Challenge: Extend the workflow with a matrix strategy to build the image against multiple Node.js versions (`['18', '20', '22']`) and upload separate test results for each. Use `matrix.node-version` in the artifact name to keep them distinct. Then add a final job that downloads all three test artifacts and compares them — simulating a compatibility matrix test that verifies CricketPulse runs correctly on all supported Node.js LTS versions.

  • The `permissions: packages: write` block is required in the job definition to push to ghcr.io — without it, the login succeeds but the push fails with 403.
  • Build with `load: true` first to load into the local Docker daemon for scanning and verification; push with `push: true` in a separate step only after scans pass.
  • Trivy with `exit-code: '1'` and `severity: 'CRITICAL,HIGH'` creates a hard quality gate — the push step never executes if critical vulnerabilities are found in the image.
  • Upload Trivy SARIF results with `if: always()` so security findings are visible in the repository's Security tab even when the scan step fails the pipeline.
  • The `type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}` metadata tag pattern ensures `latest` is only applied to main branch pushes, not to feature branches or PRs.
  • After a successful push, the image is visible at `https://github.com/OWNER/REPO/pkgs/container/IMAGE` and pullable with `docker pull ghcr.io/OWNER/REPO/IMAGE:TAG` using a personal access token.
Lesson 12 of 24
0% complete