Docker 84 🐳 Continuous Integration Pipelines: Building and Testing Docker Images in GitHub Actions
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
| Action | Purpose |
|---|---|
docker/login-action | Authenticate to registry |
docker/setup-buildx-action | Enable BuildKit builder |
docker/build-push-action | Build with cache, push or load |
Build-Push-Action Key Inputs
| Input | Effect |
|---|---|
load: true | Export image to local daemon |
push: true | Push to registry |
platforms | Target architectures |
cache-from: type=gha | Import GitHub Actions cache |
cache-to: type=gha,mode=max | Export all layers |
Cache Backends
| Type | Storage | Use When |
|---|---|---|
gha | GitHub Actions cache | Default for GitHub |
registry | Container registry | GHCR already in use |
Multi-Platform Strategies
| Strategy | Speed | Complexity |
|---|---|---|
| QEMU | Slow | Low |
| Native matrix + merge | Fast | High |
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Broken image pushed | No test stage before push | load: true + docker run |
| CI builds are slow | No persistent cache | type=gha cache |
| arm64 build times out | QEMU emulation | Native ubuntu-24.04-arm runner |
| Cache misses after code change | Cache key includes source | Cache manifests separately |
| Health test false positive | Container starts but app not ready | sleep + 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
| Item | Value |
|---|---|
| Test-before-push | load: true + docker run before push: true |
| Cache export | cache-to: type=gha,mode=max |
| Cache import | cache-from: type=gha |
| QEMU alternative | Native runners per platform |
| Multi-platform merge | docker buildx imagetools create |
| Service container | Database available at localhost:<port> |
| Credential storage | GitHub repository secrets |
| Health endpoint | /live or /ready for container probes |
Key takeaways:
- Build with
load: true, test the container, then push. Thedocker/build-push-actionexports the image to the local daemon whenloadis true, allowingdocker runto validate it. The push step rebuilds with cache reuse, so the tested and pushed artifacts are identical . type=ghacache is the default for GitHub-hosted runners. Withoutcache-fromandcache-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 runexited.
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.
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!