Project 1: Containerise an App & Ship it with CI/CD
From source code to an automatically built, tested, and published container image.
Project 1 takes your tiny app and gives it the first two pillars of production: itβs containerised so it runs anywhere, and itβs shipped by a CI/CD pipeline so every push is automatically built, tested, and published. No manual steps.
π― Learning Objectives
- Write a production-quality Dockerfile (multi-stage, non-root)
- Build a CI pipeline that tests on every push
- Automatically build and publish an image to a registry
- Tag images meaningfully for traceability
Step 1 β A Production Dockerfile
A good image is small, secure, and cached well. Use a multi-stage build (build tools stay out of the final image) and run as a non-root user:
# ---- build stage ----
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci # install deps (cached unless package files change)
COPY . .
RUN npm run build
# ---- runtime stage ----
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node # never run as root
EXPOSE 3000
HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]π‘ Order layers by change frequency
Copy package*.json and install before copying the rest of the source. Dependencies change rarely, so Docker reuses that cached layer on most builds β dramatically faster CI.
Verify it locally before automating:
docker build -t myapp:dev .
docker run -p 3000:3000 myapp:devStep 2 β The CI Pipeline (Test on Every Push)
Using GitHub Actions, the first job runs your tests on every push and pull request β the safety net that lets you move fast:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm testNow a red X on a pull request means βdonβt merge thisβ β automated, every time.
Step 3 β Build & Publish the Image
Add a second job that builds the Docker image and pushes it to a registry (here, GitHub Container Registry) β but only on main, and only after tests pass:
build-and-push:
needs: test # only if tests pass
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}β Never hardcode credentials
The registry password comes from a secret (the built-in GITHUB_TOKEN here), never from plaintext in the file. Committing credentials is one of the most common β and most damaging β mistakes. Use your CIβs secret store.
Step 4 β Tag for Traceability
Notice the two tags above: latest and the commit SHA. This matters more than it looks:
| Tag | Purpose |
|---|---|
latest |
Convenience β the newest main build |
:<git-sha> |
Immutable, traceable β every image maps to exact source code |
Deploying by SHA (not latest) means you always know exactly what code is running, and rollback is just deploying a previous SHA. This is the foundation Project 3βs GitOps will build on.
π§ͺ Hands-on Lab
Harden the Pipeline
Your pipeline builds and pushes on every push to any branch, using only a latest tag, and runs the container as root. List three concrete improvements and why each matters.
π§ Knowledge Check
Why use a multi-stage Docker build?
Why tag images with the Git commit SHA rather than only 'latest'?
πΌ Interview Preparation
Describe a good CI/CD pipeline for a containerised app.
Summary
Project 1 is done: your app is containerised with a solid Dockerfile and shipped by a CI/CD pipeline that tests, builds, and publishes traceable images automatically. Next, Project 2 provisions real infrastructure with Terraform and deploys the app to Kubernetes.