Building Images in CI
Automate the build
Building Docker images locally and pushing them manually works for one developer. With a team, you want every merge to main to automatically build and push a new image.
GitHub Actions workflow
# .github/workflows/build.yml
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/myapp:latest
${{ secrets.DOCKER_USERNAME }}/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max What each step does
Checkout code — Pulls your repository so Docker can access the Dockerfile and source code.
Set up Docker Buildx — Buildx is the modern Docker build engine. It supports layer caching across CI runs, which makes builds much faster.
Log in to Docker Hub — Authenticates with the registry. The username and password come from GitHub repository secrets (Settings → Secrets → Actions).
Build and push — Builds the image from the Dockerfile and pushes it to Docker Hub with two tags: latest (always the newest) and the git commit SHA (a unique, immutable tag for this specific build).
cache-from / cache-to — Each CI run starts with a clean machine. Without caching, every build reinstalls all dependencies from scratch (the RUN npm ci layer). type=gha stores Docker build layers in GitHub Actions’ built-in cache. On the next build, Docker pulls cached layers and skips unchanged steps — just like building locally after the first time. This can cut build times from minutes to seconds.
Setting up secrets
In your GitHub repository:
- Go to Settings → Secrets and variables → Actions
- Add
DOCKER_USERNAME(your Docker Hub username) - Add
DOCKER_PASSWORD(your Docker Hub password or access token)
For GitHub Container Registry (ghcr.io), the login is simpler:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} No extra secrets needed — GITHUB_TOKEN is provided automatically.
Adding tests before building
Run your tests before building the image. If tests fail, the image is not built:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npx tsx --test tests/*.test.ts
build:
needs: test # Only runs if tests pass
runs-on: ubuntu-latest
steps:
# ... build and push steps needs: test means the build job only runs if the test job succeeds. A failing security test blocks the build and the deploy.
Tagging strategy
latest — The most recent build. Convenient but not specific. If you pull latest on two servers at different times, they might get different images.
git SHA — The commit hash (e.g., abc123def). Unique and immutable. You can always trace an image back to exactly which code built it.
Semantic version — 1.2.3. For releases. Requires manual tagging or a release process.
Use both latest (for convenience) and SHA (for traceability) on every build.
Exercises
Exercise 1: Create the GitHub Actions workflow. Push to main and verify the image appears on Docker Hub.
Exercise 2: Add the test job. Push code with a failing test. Verify the build does not run.
Exercise 3: Check the image tags on Docker Hub. You should see latest and the commit SHA.
Why tag images with the git commit SHA in addition to 'latest'?