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.
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.
# 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.
# 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.
# 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-testStep 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.
# 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.
# 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 versionWarning: 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.