Docker – Building and Pushing from GitHub Actions

August 11, 20264 min readUpdated 8/21/2026

Building images by hand works until it doesn't: someone builds from an uncommitted branch, someone forgets to push, someone's laptop produces an arm64 image nobody notices until deploy. CI removes the human from the loop.

What follows is the workflow from the demo application, in pieces.

Tests first, in their own job

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      - name: Run the test suite
        working-directory: pizza-springboot-backend
        run: ./mvnw -B test

The image's Dockerfile builds with -DskipTests (lesson 4), and this is the other half of that decision. Tests in a separate job report a failure against the commit with the report attached, and no image is built at all. Tests inside the Dockerfile make every image build pay for them and turn a flaky test into a failed deploy with no useful output.

cache: maven caches ~/.m2 keyed on the pom files. A runner is a fresh machine every time, so without it every run re-downloads the whole dependency tree — the CI equivalent of getting the COPY order wrong in a Dockerfile.

Permissions, and not storing a password

    permissions:
      contents: read
      packages: write

This is the part worth copying even if you use nothing else here. GitHub mints a GITHUB_TOKEN for each run, scoped to this repository, expiring when the job ends. With packages: write it can push to GHCR.

So there is no long-lived registry credential in the repository secrets — nothing to leak, nothing to rotate, nothing that keeps working after someone leaves. For AWS or GCP the equivalent is OIDC federation: the runner presents its identity token and the cloud hands back short-lived credentials, with no access key stored anywhere.

One job per image

    strategy:
      fail-fast: false
      matrix:
        include:
          - name: pizza-api
            context: ./pizza-springboot-backend
          - name: pizza-web
            context: ./pizza-react-frontend
          - name: pizza-web-angular
            context: ./pizza-angular-frontend

Three parallel jobs from one definition. fail-fast: false matters — the default cancels the siblings the moment one fails, so a broken frontend build hides whether the API would have built.

The build steps

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - name: Log in to the registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

QEMU for cross-architecture emulation, buildx because plain docker build cannot do multi-platform or inline cache (lesson 19).

The if: on the login is a small thing that prevents a real annoyance: a pull request from a fork gets a read-only token, so this step would fail. Skipping it lets the build still run — it just does not push, which is exactly what you want from an untrusted PR.

Tags from the git ref

      - name: Work out the tags
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository }}/${{ matrix.name }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

Lesson 18's tagging scheme, derived rather than typed. A push to main produces :main, :sha-a3f9c21 and :latest; a v1.4.2 tag also produces :1.4.2 and :1.4.

Deploy the sha- tag. :latest is produced here for humans typing docker run, not for a deployment to reference.

The cache line that decides your CI time

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: ${{ matrix.context }}
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Every runner is a fresh machine with an empty layer cache. So all the careful COPY ordering from lesson 6 buys nothing in CI by default — every run reinstalls every dependency, in the one place it hurts most.

type=gha stores the layer cache in GitHub's own Actions cache and restores it next run.

mode=max is the important half. The default (min) caches only layers present in the final image — and in a multi-stage build the expensive layer (npm ci, mvn package) is in the build stage, which never appears in the result. Without mode=max you cache the cheap layers and rebuild the expensive ones.

Two caveats: GitHub's Actions cache has a 10 GB per-repository limit and evicts least-recently-used entries, so several large images can push each other out. And cache is scoped per branch, with fallback to the default branch — so the first build on a new branch is slow, and that is normal.

What this workflow does not do

It builds and pushes. It does not deploy — that is a separate concern, usually a separate workflow triggered by this one, and it should reference the sha- tag this run produced.

⚠️ And one honest note about the file itself: it has never run. The demo project has no GitHub remote yet, so this is written to be correct rather than proven. Every underlying command was verified locally with the equivalent docker buildx invocation; the workflow wiring has not been. Its own header says so, which is the right place for that information to live.

Next: what changes when it is not your laptop.