sehaj/learn
Docker/07 Production

07.06 · build · 30 min

Project: ship with GitHub Actions

A GitHub Actions workflow that builds the notes API, refuses to ship fixable HIGH vulnerabilities, and publishes multi-platform images to GHCR tagged by commit.

The brief

The workflow, part by part

When it runs. Only when the API or the workflow itself changes, plus a manual button:

.github/workflows/notes-api.yml
on:
push:
  branches: [main]
  paths:
    - "project/docker/api/**"
    - ".github/workflows/notes-api.yml"
pull_request:
  paths:
    - "project/docker/api/**"
    - ".github/workflows/notes-api.yml"
workflow_dispatch:

permissions:
contents: read
packages: write

permissions gives the job's automatic GITHUB_TOKEN exactly what it needs: read the code, write packages. No personal token is stored anywhere.

Build and scan before publishing. The image is built once for the runner's platform and loaded locally, so the scan runs before anything leaves the runner:

.github/workflows/notes-api.yml
    - uses: actions/checkout@v7
    - uses: docker/setup-qemu-action@v4
    - uses: docker/setup-buildx-action@v4

    - name: Build for scanning
      uses: docker/build-push-action@v7
      with:
        context: ${{ env.CONTEXT }}
        load: true
        tags: notes-api:scan
        cache-from: type=gha
        cache-to: type=gha,mode=max

    - name: Scan with Trivy
      run: |
        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
          aquasec/trivy:0.76.1 image --scanners vuln --severity HIGH,CRITICAL \
          --ignore-unfixed --exit-code 1 notes-api:scan

The scan is the same gate as in 07.03 Scanning images, with a pinned Trivy version so results don't change underneath you.

Tags. docker/metadata-action turns the commit into tags:

.github/workflows/notes-api.yml
    - name: Image tags
      id: meta
      uses: docker/metadata-action@v6
      with:
        images: ${{ env.IMAGE }}
        tags: |
          type=sha,prefix=sha-
          type=raw,value=latest,enable={{is_default_branch}}

Publish. Log in to GHCR with the built-in token, then build both platforms and push. The amd64 layers come straight from the cache the scan build just wrote:

.github/workflows/notes-api.yml
    - name: Log in to GHCR
      if: github.event_name != 'pull_request' && vars.PUBLISH_IMAGE == 'true'
      uses: docker/login-action@v4
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push (amd64 + arm64)
      if: github.event_name != 'pull_request' && vars.PUBLISH_IMAGE == 'true'
      uses: docker/build-push-action@v7
      with:
        context: ${{ env.CONTEXT }}
        platforms: linux/amd64,linux/arm64
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

Two guards on publishing: pull requests never publish (code from a fork shouldn't push images), and a repository variable, PUBLISH_IMAGE, must be true. That makes publishing a deliberate switch. Set it under the repo's Settings → Secrets and variables → Actions → Variables, or with gh variable set PUBLISH_IMAGE --body true.

What the real runs looked like

❯terminal
$ gh run view 41526039809
✓ image in 1m29s
✓ Run actions/checkout@v5
✓ Run docker/setup-qemu-action@v3
✓ Run docker/setup-buildx-action@v3
✓ Build for scanning
✓ Scan with Trivy
✓ Image tags
- Log in to GHCR
- Build and push (amd64 + arm64)
ANNOTATIONS
! Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24:
docker/build-push-action@v6, docker/metadata-action@v5, docker/setup-buildx-action@v3, docker/setup-qemu-action@v3.

Output trimmed. The first run passed the scan and skipped publishing (the variable wasn't set yet). It also flagged that the action versions were a major behind, so the workflow moved to checkout@v7, setup-qemu-action@v4, setup-buildx-action@v4, build-push-action@v7, metadata-action@v6 and login-action@v4. The second run had no warnings and reused the cache (07.05 Build cache in CI). Read your CI warnings; they're cheap to fix early.

Then publishing was switched on and the workflow started by hand:

❯terminal
$ gh variable set PUBLISH_IMAGE --body true
$ gh workflow run notes-api.yml --ref main
$ gh run view 41528110439
✓ Build for scanning 7s
✓ Scan with Trivy 13s
✓ Image tags 1s
✓ Log in to GHCR 0s
✓ Build and push (amd64 + arm64) 59s

(Step timings from the run.) The scan build took 7 seconds because every layer came from the cache. Then, from a laptop that wasn't logged in to GHCR:

❯terminal
$ docker buildx imagetools inspect ghcr.io/sehajmakkar/notes-api:latest
Name: ghcr.io/sehajmakkar/notes-api:latest
MediaType: application/vnd.oci.image.index.v1+json
Platform: linux/amd64
Platform: linux/arm64
Platform: unknown/unknown
Platform: unknown/unknown
$ docker pull ghcr.io/sehajmakkar/notes-api:sha-9f30a1d
$ docker image inspect -f '{{.Os}}/{{.Architecture}} user={{.Config.User}}' ghcr.io/sehajmakkar/notes-api:sha-9f30a1d
linux/arm64 user=node
$ docker run --rm --platform linux/amd64 --entrypoint node ghcr.io/sehajmakkar/notes-api:sha-9f30a1d -p 'process.arch'
x64
$ docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' ghcr.io/sehajmakkar/notes-api:sha-9f30a1d
9f30a1da127b447ea9efb68edd3864afb195c9e1

Output trimmed. Both platforms are there (the unknown/unknown entries are the build's attestations, 07.04 Multi-platform builds). The Mac pulled the arm64 image, the amd64 one runs under emulation, and the label added by metadata-action records the exact commit the image came from. Because this repository is public, the package was readable without logging in.

Predict first

A pull request from someone's fork changes the API. What does this workflow do?

Commit to an answer before you read on.
CheckpointProject step 6: ship from CI
  • Add the workflow to your fork, push, and watch it run in the Actions tab or with gh run watch.
  • Open a PR that changes server.ts and confirm it builds and scans without publishing.
  • Set PUBLISH_IMAGE to true, push to main, and find your image under your GitHub profile's Packages. Pull it on a machine with the other architecture.
  • Check the package's visibility in its settings. If it's private, the server needs docker login ghcr.io before it can pull (07.07 Project: deploy to a VPS); make it public if you'd rather not.
STUCK? ▸

Push fails with denied: permission_denied: check permissions: packages: write in the workflow, and that the repository is allowed to write to the package (the package's Settings → Manage Actions access). The scan fails: read the table Trivy printed; it lists each package with its fixed version.

Interview questions

MidfreqMust-knowDesign

Describe a CI pipeline for building and publishing Docker images.

Report a mistakeEdit this page

Used in