| |

Docker 84 🐳 Continuous Integration Pipelines: Building and Testing Docker Images in GitHub Actions

⏱️ Estimated read: 13 min | |

A CI pipeline for Docker images is not a single build step. It is a sequence of decisions: build the image without pushing, run the container to verify it starts, check a health endpoint, then rebuild with the same cache and push the tested artifact. GitHub Actions provides the primitives for this sequence through Docker’s official actions—docker/login-action, docker/setup-buildx-action, and docker/build-push-action—but the order and configuration determine whether the pipeline catches broken images or silently ships them.

The critical pattern is test-before-push. Building an image with push: false and load: true exports it to the local Docker daemon, allowing docker run to validate that the application starts and responds before any registry credential is used . The push step then rebuilds from the same BuildKit cache, so the tested image and the pushed image are identical without paying the full build cost twice. This chapter covers that workflow, the layer caching strategies that make CI builds fast, the multi-platform build patterns that avoid QEMU emulation, and the service containers that provide databases for integration tests.

Key point: Build once for testing, test the container, then build and push with cache reuse. The push: false + load: true combination validates the artifact before it reaches a registry, and cache-from: type=gha ensures the push step does not rebuild from scratch.


Why CI pipelines need a test-before-push stage

A built image is not a working image. Docker can successfully compile a Dockerfile into an image that exits immediately at runtime: a missing environment variable, a broken entrypoint, a port mismatch, or a dependency that fails to load. The build log shows success; the container crashes. Pushing that image to a registry makes it available to deployment systems that will fail in production. The test stage catches these failures where they are cheap to fix .

The build and push steps share cache. The Docker documentation’s test-before-push workflow builds the image once with load: true (exporting to the local daemon), runs docker run --rm $TEST_TAG, then invokes build-push-action again with push: true. The second build does not recompile linux/amd64; it reuses the internal cache from the first build and only adds the linux/arm64 platform if multi-platform is configured . This means the tested image and the pushed image are the same artifact, and the push is fast.

CI runners start clean, so caching must be explicit. GitHub-hosted runners discard their filesystem after every job. BuildKit’s default cache does not persist across workflow runs. The cache-from and cache-to directives with type=gha store layer cache in GitHub’s Actions cache, allowing subsequent runs to reuse unchanged layers . Without this, every CI run compiles every dependency from scratch, turning a thirty-second build into a three-minute one .

Credentials must never appear in logs. Docker Hub tokens belong in GitHub repository secrets, referenced as ${{ secrets.DOCKERHUB_TOKEN }}. The login action authenticates, the build action pushes, and the token is masked in logs. Hardcoding credentials or echoing them in run steps exposes them to anyone with repository read access.

The trade-off. The test-before-push pattern adds a docker run step and a second build invocation to the workflow. For a simple image with no runtime dependencies, the test is a single docker run --rm that verifies the entrypoint. For a web service, it requires a health endpoint and a port mapping. The cost is a few seconds of CI time; the benefit is never publishing a broken image.


a. The core workflow: build, load, test, push

The workflow starts with authentication, Buildx setup, and a build that exports to the local daemon. The load: true flag is the mechanism: it tells BuildKit to write the image into the Docker engine’s image store, making it available for docker run. The push: false flag prevents any registry interaction at this stage.

name: ci
on:
  push:

env:
  TEST_TAG: user/app:test
  LATEST_TAG: user/app:latest

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Login to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Build and export to Docker
        uses: docker/build-push-action@v7
        with:
          load: true
          tags: ${{ env.TEST_TAG }}

      - name: Test
        run: |
          docker run --rm ${{ env.TEST_TAG }}

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ env.LATEST_TAG }}

The test command docker run --rm $TEST_TAG is the simplest validation: it runs the container and checks that it exits with code zero. For a web service, this is insufficient—the container starts and stays running, so docker run does not return. The test must run the container in detached mode, wait for the application to accept connections, then probe an endpoint. A health endpoint like /live or /ready provides a stable target for this probe .

- name: Test health endpoint
  run: |
    docker run --name app-test -d -p 3000:3000 ${{ env.TEST_TAG }}
    # Wait for the application to start.
    sleep 5
    # Probe the health endpoint; fail if it returns non-200.
    wget -q -O - http://127.0.0.1:3000/live | grep live
    docker stop app-test

The grep verifies that the response contains the expected string. A more robust check uses curl --fail with a retry loop, but the principle is the same: the test fails the workflow if the container does not serve a healthy response.


b. Layer caching: type=gha and cache-from/cache-to

BuildKit cache stored locally does not survive between GitHub Actions runs. The cache-from and cache-to directives with type=gha export and import cache layers through GitHub’s Actions cache service. The mode=max setting caches all layers, not just the final image layers, maximizing reuse .

- name: Build and push with cache
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: user/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The impact is measurable. A workflow that rebuilt a GDAL and Python dependency image on every push took three to four minutes; after adding registry cache with cache-from: type=registry and cache-to: type=registry,mode=max, unchanged layers were reused and code-only changes built in under a minute . The type=gha backend uses GitHub’s own storage, avoiding the need for an external registry cache repository, but type=registry is also viable when GHCR is already in use.

A subtlety with multi-platform builds: each platform maintains its own cache scope. If linux/amd64 and linux/arm64 share a single cache key, they can evict each other’s layers. The fix is per-platform cache scopes, such as cache-from: type=gha,scope=build-amd64 and cache-from: type=gha,scope=build-arm64 .


c. Multi-platform builds: native runners instead of QEMU

Building linux/arm64 on an ubuntu-latest (amd64) runner requires QEMU emulation. QEMU works, but it is slow: a Next.js build that takes minutes natively can take thirty or more minutes under emulation . The alternative is native runners. GitHub provides ubuntu-24.04-arm for ARM64 builds, and a matrix strategy runs the build on each architecture’s native hardware in parallel.

The native pattern splits the workflow into two jobs: a build job that pushes each platform by digest, and a merge job that creates the multi-architecture manifest list. The build job uses a matrix with platform and runner pairs.

jobs:
  build:
    strategy:
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-latest
          - platform: linux/arm64
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v4
      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v6
        with:
          platforms: ${{ matrix.platform }}
          outputs: type=image,name=user/app,push-by-digest=true,name-canonical=true,push=true
      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"
      - uses: actions/upload-artifact@v4
        with:
          name: digests-${{ matrix.platform }}
          path: /tmp/digests/*

  merge:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true
      - uses: docker/setup-buildx-action@v4
      - name: Create manifest list and push
        run: |
          cd /tmp/digests
          docker buildx imagetools create \
            -t user/app:latest \
            $(printf 'user/app@sha256:%s ' *)

The merge job downloads the digest artifacts from both builds and uses imagetools create to assemble the manifest list. The resulting user/app:latest tag points to a multi-architecture image that Docker resolves to the correct platform at pull time . The build time becomes max(amd64, arm64) instead of amd64 + arm64 × QEMU_factor, which for the Next.js example dropped from forty minutes to single-digit minutes .


Complete Example Session

# ============================================
# PART 1: WORKFLOW TRIGGER AND ENVIRONMENT
# ============================================
name: docker-ci
on:
  push:
    branches: [main]
  pull_request:

env:
  TEST_TAG: ${{ github.repository }}:test
  LATEST_TAG: ${{ github.repository }}:latest

# ============================================
# PART 2: BUILD AND TEST JOB
# ============================================
jobs:
  build-test-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # ============================================
      # PART 3: AUTHENTICATE FOR PUSH (NOT FOR TEST BUILD)
      # ============================================
      - name: Login to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # ============================================
      # PART 4: BUILDX SETUP FOR CACHE AND MULTI-PLATFORM
      # ============================================
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # ============================================
      # PART 5: BUILD FOR TESTING (LOAD, NO PUSH)
      # ============================================
      - name: Build for testing
        uses: docker/build-push-action@v7
        with:
          load: true
          tags: ${{ env.TEST_TAG }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      # ============================================
      # PART 6: CONTAINER TEST
      # ============================================
      - name: Run container and verify health
        run: |
          docker run --name test-app -d -p 3000:3000 ${{ env.TEST_TAG }}
          sleep 5
          wget -q -O - http://127.0.0.1:3000/live | grep live
          docker stop test-app

      # ============================================
      # PART 7: PUSH TESTED IMAGE WITH CACHE REUSE
      # ============================================
      - name: Build and push
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v7
        with:
          push: true
          tags: ${{ env.LATEST_TAG }}
          platforms: linux/amd64,linux/arm64
          cache-from: type=gha

# ============================================
# PART 8: NATIVE MULTI-PLATFORM BUILD (ALTERNATIVE)
# ============================================
# Matrix build per platform, push by digest.
# Merge job creates the manifest list.
# Avoids QEMU emulation entirely.

# ============================================
# PART 9: SERVICE CONTAINERS FOR INTEGRATION TESTS
# ============================================
# jobs:
#   test:
#     services:
#       postgres:
#         image: postgres:16
#         env:
#           POSTGRES_PASSWORD: test
#         ports: ["5432:5432"]
#         options: >-
#           --health-cmd pg_isready
#           --health-interval 5s
#
# Service containers run alongside the job;
# tests connect via localhost:5432.

# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. Checkout → Buildx → build with load:true.
# 2. docker run → health check.
# 3. Build+push with same cache.
# 4. cache-from type=gha reuses layers.
# 5. Multi-platform via native runners.

Quick Reference

Docker Actions

ActionPurpose
docker/login-actionAuthenticate to registry
docker/setup-buildx-actionEnable BuildKit builder
docker/build-push-actionBuild with cache, push or load

Build-Push-Action Key Inputs

InputEffect
load: trueExport image to local daemon
push: truePush to registry
platformsTarget architectures
cache-from: type=ghaImport GitHub Actions cache
cache-to: type=gha,mode=maxExport all layers

Cache Backends

TypeStorageUse When
ghaGitHub Actions cacheDefault for GitHub
registryContainer registryGHCR already in use

Multi-Platform Strategies

StrategySpeedComplexity
QEMUSlowLow
Native matrix + mergeFastHigh

Best Practices

✅ Do This:

# Build with load:true, test, then push.                    // ✅
- uses: docker/build-push-action@v7
  with: { load: true, tags: test }
- run: docker run --rm test

# Use type=gha for cache.                                   // ✅
cache-from: type=gha
cache-to: type=gha,mode=max

# Run containers detached and probe health.                 // ✅
docker run -d -p 3000:3000 test
wget -q -O - http://localhost:3000/live

# Use native ARM runners for arm64 builds.                  // ✅
runs-on: ubuntu-24.04-arm

❌ Don’t Do This:

# Push before testing.                                      // ❌
- uses: docker/build-push-action@v7
  with: { push: true }

# Echo Docker Hub token.                                    // ❌
run: echo ${{ secrets.DOCKERHUB_TOKEN }}

# Build arm64 under QEMU on amd64 runner.                   // ❌
# Minutes become tens of minutes.

# Share cache scope across platforms.                       // ❌
# amd64 and arm64 layers evict each other.

Common Pitfalls

PitfallWhy It HappensFix
Broken image pushedNo test stage before pushload: true + docker run
CI builds are slowNo persistent cachetype=gha cache
arm64 build times outQEMU emulationNative ubuntu-24.04-arm runner
Cache misses after code changeCache key includes sourceCache manifests separately
Health test false positiveContainer starts but app not readysleep + retry loop

Real-World Examples

1. Build and Export for Testing

- uses: docker/build-push-action@v7
  with: { load: true, tags: test:latest }

2. Run and Probe Health

- run: |
    docker run -d -p 3000:3000 test:latest
    sleep 5 && wget -q -O - http://localhost:3000/live

3. Push with Cache Reuse

- uses: docker/build-push-action@v7
  with:
    push: true
    cache-from: type=gha

4. Export Cache

cache-to: type=gha,mode=max

5. Native ARM Runner

runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}

6. Push by Digest

outputs: type=image,name=user/app,push-by-digest=true,push=true

7. Merge Manifest

docker buildx imagetools create -t user/app:latest user/app@sha256:...

8. Service Container for Tests

services:
  postgres:
    image: postgres:16
    ports: ["5432:5432"]

9. Docker Hub Login

- uses: docker/login-action@v4
  with:
    username: ${{ vars.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}

10. Buildx Setup

- uses: docker/setup-buildx-action@v4

Visual

Test-Before-Push Flow

┌──────────────────────────────────────────────┐
│  BUILD → LOAD → TEST → PUSH                  │
│                                              │
│  build-push-action (load:true)               │
│       │                                      │
│       ▼                                      │
│  Local Docker daemon                         │
│       │                                      │
│       ▼                                      │
│  docker run --rm test:tag                    │
│       │                                      │
│       ├── Exit 0 → continue                  │
│       └── Exit ≠0 → fail workflow            │
│                                              │
│  build-push-action (push:true)               │
│       │                                      │
│       ▼                                      │
│  Cache reuse from first build                │
│       │                                      │
│       ▼                                      │
│  Registry                                    │
│                                              │
│  Tested image == pushed image.               │
│                                              │
└──────────────────────────────────────────────┘

Cache Layer Reuse

┌──────────────────────────────────────────────┐
│  FIRST RUN            SECOND RUN             │
│                                              │
│  FROM node:18         FROM node:18 (cached)  │
│  COPY package.json    COPY package.json (cached)│
│  RUN npm install      RUN npm install (cached)│
│  COPY . .             COPY . . (CHANGED)     │
│  RUN npm build        RUN npm build (runs)   │
│                                              │
│  First: 3 min         Second: 40 sec         │
│                                              │
│  cache-from: type=gha imports layers.        │
│  cache-to: type=gha,mode=max exports them.   │
│                                              │
└──────────────────────────────────────────────┘

Native Multi-Platform Build

┌──────────────────────────────────────────────┐
│  MATRIX BUILD + MERGE                        │
│                                              │
│  ┌──────────────────┐  ┌──────────────────┐ │
│  │ ubuntu-latest    │  │ ubuntu-24.04-arm │ │
│  │ linux/amd64      │  │ linux/arm64      │ │
│  │ build → digest A │  │ build → digest B │ │
│  └────────┬─────────┘  └────────┬─────────┘ │
│           │                     │            │
│           └──────────┬──────────┘            │
│                      ▼                       │
│           ┌─────────────────────┐            │
│           │ Merge Job           │            │
│           │ imagetools create   │            │
│           │ manifest: latest    │            │
│           └─────────────────────┘            │
│                                              │
│  Time = max(amd64, arm64) + merge            │
│  No QEMU emulation.                          │
│                                              │
└──────────────────────────────────────────────┘

Service Container Testing

┌──────────────────────────────────────────────┐
│  GITHUB ACTIONS JOB                          │
│                                              │
│  ┌────────────────────────────────────┐      │
│  │ Service Container: postgres:16     │      │
│  │ Port 5432 → localhost:5432         │      │
│  └────────────────────────────────────┘      │
│         │                                    │
│         │ job steps connect to               │
│         │ localhost:5432                     │
│         ▼                                    │
│  ┌────────────────────────────────────┐      │
│  │ Test runner                        │      │
│  │ pytest / npm test                  │      │
│  │ DATABASE_URL=postgresql://...      │      │
│  └────────────────────────────────────┘      │
│                                              │
│  Isolated database per job run.              │
│  No production data touched.                 │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Test-before-pushload: true + docker run before push: true
Cache exportcache-to: type=gha,mode=max
Cache importcache-from: type=gha
QEMU alternativeNative runners per platform
Multi-platform mergedocker buildx imagetools create
Service containerDatabase available at localhost:<port>
Credential storageGitHub repository secrets
Health endpoint/live or /ready for container probes

Key takeaways:

  • Build with load: true, test the container, then push. The docker/build-push-action exports the image to the local daemon when load is true, allowing docker run to validate it. The push step rebuilds with cache reuse, so the tested and pushed artifacts are identical .
  • type=gha cache is the default for GitHub-hosted runners. Without cache-from and cache-to, every CI run compiles every layer. With them, unchanged layers are reused, and a three-minute build becomes thirty seconds .
  • QEMU emulation is a timeout waiting to happen. Native ARM runners (ubuntu-24.04-arm) build arm64 images at full speed. The matrix-plus-merge pattern produces a multi-architecture manifest without emulation .
  • Service containers isolate integration tests. A PostgreSQL service container runs alongside the job, reachable at localhost:5432, with no impact on production databases .
  • Credentials belong in secrets, not YAML. ${{ secrets.DOCKERHUB_TOKEN }} is masked in logs. Any other approach leaks the token to anyone with repository access.
  • The health endpoint is the test contract. A container that starts is not a container that serves. The test stage must probe an endpoint and verify the response, not merely check that docker run exited.

Remember: A CI pipeline for Docker images is a sequence of trust decisions. The build step says “this Dockerfile compiles.” The test step says “this container starts and serves.” The push step says “this tested artifact is now available.” Skipping the test step collapses three decisions into one, and the one it collapses into is wrong. Cache the layers so the sequence is fast, use native runners so the sequence is not dominated by emulation, and isolate the database so the tests do not touch production. The pipeline is not a build script; it is a gate.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!