Module 5: Docker in CI/CD
7. Multi-Platform Builds
Overview
So far, all your images get built for a single architecture: linux/amd64 — the one GitHub Actions runners and most cloud servers use. It works. But there are two increasingly common scenarios where you need support for linux/arm64:
-
Apple Silicon (M1/M2/M3/M4): Your team uses Macs with an Apple Silicon chip. When they download your amd64 image, Docker runs it with Rosetta emulation — slower and with possible incompatibilities.
-
AWS Graviton / Azure Arm: ARM instances in the cloud are ~20% cheaper and ~30% more energy-efficient. If you deploy on Graviton, you need native arm64 images.
Multi-platform builds build the same image for multiple architectures in a single build. The registry stores both variants under the same tag, and Docker automatically downloads the right one depending on the machine doing the pull.
This capsule is short by design. Multi-platform is a capability, not an obligation. If you deploy only on amd64 (most cases), this capsule is informational. If you need arm64, here's the complete configuration.
When you need multi-platform
You need multi-platform if:
- 📋 Your team has Macs with Apple Silicon (M1/M2/M3/M4) and you want native images for local development
- 📋 You deploy on AWS Graviton (
t4g,c7g,m7ginstances) - 📋 You deploy on Azure ARM-based VMs
- 📋 You distribute public images and you want universal support
- 📋 Your CI/CD target includes both x86 and ARM
You do NOT need multi-platform if:
- 📋 You deploy only on x86 servers (most current cloud infrastructure)
- 📋 Your team uses Linux/Windows x86 for development
- 📋 Your team uses Macs with Apple Silicon but Docker Desktop with Rosetta works fine
- 📋 The cost of the extra build time doesn't justify the benefit
The honest decision
Does your whole team deploy on x86?
→ You don't need multi-platform now
→ You can add it later when you need it
Does your team use Apple Silicon for development?
→ Multi-platform improves their experience
→ But it isn't blocking — Rosetta works
Do you deploy on ARM (Graviton, etc.)?
→ You need multi-platform — it's mandatory
How multi-platform works
The concept: manifest lists
When you do docker pull myapp:v1.0.0, Docker doesn't download an image — it downloads a manifest that lists the available variants:
{
"manifests": [
{
"platform": { "architecture": "amd64", "os": "linux" },
"digest": "sha256:abc123..."
},
{
"platform": { "architecture": "arm64", "os": "linux" },
"digest": "sha256:def456..."
}
]
}
Docker detects your machine's architecture and downloads the right variant:
Mac M2 (arm64): docker pull myapp:v1.0.0 → downloads sha256:def456 (arm64)
Linux x86 server: docker pull myapp:v1.0.0 → downloads sha256:abc123 (amd64)
The same tag, the same command, the right image automatically.
QEMU: architecture emulation
GitHub Actions runners are linux/amd64. To build arm64 images on an amd64 runner, you need QEMU — an emulator that lets you run instructions from a different architecture.
docker/setup-qemu-action installs QEMU on the runner:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
With QEMU installed, Buildx can build images for linux/arm64 by running the Dockerfile's commands in an emulated environment.
Configuration in GitHub Actions
The basic multi-platform workflow
# .github/workflows/docker.yml
name: Docker Multi-Platform Build
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (multi-platform)
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The three key steps
# 1. QEMU — the architecture emulator
- uses: docker/setup-qemu-action@v3
# It installs QEMU to emulate arm64 on the amd64 runner
# 2. Buildx — the advanced builder
- uses: docker/setup-buildx-action@v3
# It configures Docker Buildx with multi-platform support
# 3. Build with platforms
- uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
# It builds the image for both architectures
Output in the logs
Run docker/build-push-action@v6
Building for platforms: linux/amd64, linux/arm64
#1 [linux/amd64] FROM python:3.12-slim@sha256:...
#1 DONE 1.2s
#2 [linux/arm64] FROM python:3.12-slim@sha256:...
#2 DONE 1.5s
#3 [linux/amd64 2/5] WORKDIR /app
#3 DONE 0.1s
#4 [linux/arm64 2/5] WORKDIR /app
#4 DONE 0.1s
...
#15 exporting to image
#15 pushing manifest for ghcr.io/user/repo:abc123
#15 DONE 3.2s
Buildx builds both variants and pushes them as a manifest list under the same tag.
The impact on build time
Before vs after adding arm64
| Scenario | amd64 only | amd64 + arm64 | Increase |
|---|---|---|---|
| A simple build (few deps) | 45s | 90s | +100% |
| A medium build (fastapi, langchain) | 120s | 240s | +100% |
| A heavy build (torch) | 300s | 600s+ | +100%+ |
The build time roughly doubles because each architecture gets built sequentially (QEMU emulates arm64, which is slower than native). With a cache, the increase is smaller in subsequent builds.
Optimization: native builds with a matrix
If the build time is unacceptable, you can use native runners for each architecture:
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@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: true
tags: ghcr.io/${{ github.repository }}
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
This uses a native ARM runner for the arm64 build — no emulation, maximum speed. It requires GitHub Actions to have ARM runners available (increasingly common).
Dockerfile compatibility
Most Dockerfiles work without changes
If your Dockerfile uses a base image that supports multi-platform (like python:3.12-slim, which has amd64 and arm64 variants), you don't need to change anything:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Docker automatically selects the right variant of python:3.12-slim for each architecture.
When the Dockerfile needs changes
If you install system packages with binaries compiled specifically for one architecture:
# ❌ This can fail on arm64 if the .deb is only for amd64
RUN wget https://example.com/tool_amd64.deb && dpkg -i tool_amd64.deb
# ✅ Use the build variable to select the right binary
ARG TARGETARCH
RUN wget https://example.com/tool_${TARGETARCH}.deb && dpkg -i tool_${TARGETARCH}.deb
TARGETARCH is a variable Buildx injects automatically — it's worth amd64 or arm64 depending on the platform being built.
The available platform variables
ARG TARGETPLATFORM # linux/amd64 or linux/arm64
ARG TARGETOS # linux
ARG TARGETARCH # amd64 or arm64
ARG TARGETVARIANT # v7 (for arm/v7) or empty
ARG BUILDPLATFORM # The builder's platform (always linux/amd64 on GH Actions)
ARG BUILDOS # linux
ARG BUILDARCH # amd64
Verifying the multi-platform image
With docker manifest inspect
docker manifest inspect ghcr.io/user/ai-api:v1.0.0
The output:
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc123...",
"size": 1234,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:def456...",
"size": 1234,
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}
If you see both architectures in the list, the image is multi-platform.
In the workflow
- name: Verify multi-platform
run: |
docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ github.sha }}
Comparisons
Single-platform vs Multi-platform
| Aspect | Single (amd64 only) | Multi (amd64 + arm64) |
|---|---|---|
| Build time | Fast | ~2x slower |
| Complexity | Minimal | It requires QEMU + extra config |
| Compatibility | x86 only | x86 + native ARM |
| Apple Silicon | Emulation (Rosetta) | Native |
| AWS Graviton | ❌ Not compatible | ✅ Native |
| When to use it | Deploying only on x86 | Deploying on ARM, or a team with Apple Silicon |
QEMU (emulated) vs native runners
| Aspect | QEMU on ubuntu-latest | A native ARM runner |
|---|---|---|
| Setup | docker/setup-qemu-action | A runner with the ubuntu-24.04-arm label |
| arm64 speed | Slow (emulated, ~2-5x slower) | Fast (native) |
| Cost | No extra cost (it uses the same runner) | An additional runner |
| Availability | Always available | It depends on the GitHub plan |
| When to use it | Occasional builds, small images | Frequent builds, heavy images |
Troubleshooting
1. "exec format error" when running the image locally
Symptom:
exec /usr/local/bin/python: exec format error
Cause: You downloaded the arm64 variant on an amd64 machine (or vice versa).
Solution:
# Force the right platform
docker pull --platform linux/amd64 ghcr.io/user/ai-api:v1.0.0
docker run --platform linux/amd64 ghcr.io/user/ai-api:v1.0.0
2. The arm64 build fails with a "segmentation fault"
Symptom: The arm64 build fails with a segfault during pip install or apt-get install.
Cause: QEMU can have bugs with certain compiled packages. This is especially common with packages that include complex native code (numpy, torch).
Solution:
# Option 1: Use cross-compilation hints
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
# Option 2: Install only precompiled packages (wheels)
# In requirements.txt, specify versions with arm64 wheels available
If the problem persists with heavy packages like torch, consider native builds with ARM runners.
3. "no matching manifest for linux/arm64" in the base image
Symptom:
ERROR: no matching manifest for linux/arm64/v8 in the manifest list entries
Cause: The base image has no arm64 variant. Not every image supports multiple architectures.
Solution: Verify that your base image supports arm64:
docker manifest inspect python:3.12-slim | grep architecture
# It should show "amd64" and "arm64"
Official images of Python, Node, Ubuntu, and Alpine support multi-platform. Custom or less common images may not.
Exercises
Exercise 1: Add multi-platform to an existing build
You have this single-platform workflow. Add support for linux/amd64 and linux/arm64:
name: Docker
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:test
See solution
name: Docker
on: push
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build multi-platform
uses: docker/build-push-action@v6
with:
context: .
push: false
platforms: linux/amd64,linux/arm64
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The changes:
docker/setup-qemu-action@v3enables arm64 emulationplatforms: linux/amd64,linux/arm64builds for both architecturestimeout-minutes: 30increased because multi-platform takes longerpush: falsebecause we're only validating the build (we don't log in to the registry)- The cache with
type=ghaspeeds up subsequent builds
Exercise 2: A Dockerfile with TARGETARCH
Create a Dockerfile that downloads a different binary depending on the architecture. The binary gets downloaded from https://releases.example.com/tool-<arch> where <arch> is amd64 or arm64.
See solution
FROM python:3.12-slim
ARG TARGETARCH
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://releases.example.com/tool-${TARGETARCH}" \
-o /usr/local/bin/tool && \
chmod +x /usr/local/bin/tool
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Key points:
ARG TARGETARCHautomatically receivesamd64orarm64from Buildx- You don't need to declare a default — Buildx injects it
${TARGETARCH}expands to the right value for each platform- On the amd64 build: it downloads
tool-amd64 - On the arm64 build: it downloads
tool-arm64
Exercise 3: Multi-platform build + push + verify
Create a workflow that builds for amd64 + arm64, pushes to GHCR, and verifies that both platforms are in the manifest.
See solution
name: Docker Multi-Platform
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify platforms
run: |
echo "=== Manifest inspection ==="
docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ github.sha }}
echo ""
echo "=== Checking platforms ==="
PLATFORMS=$(docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ github.sha }} --raw | python3 -c "
import json, sys
data = json.load(sys.stdin)
platforms = [f\"{m['platform']['os']}/{m['platform']['architecture']}\" for m in data.get('manifests', [])]
print(', '.join(platforms))
")
echo "Available platforms: $PLATFORMS"
if echo "$PLATFORMS" | grep -q "linux/amd64" && echo "$PLATFORMS" | grep -q "linux/arm64"; then
echo "Both platforms present"
else
echo "ERROR: Missing platform!"
exit 1
fi
Key points:
docker buildx imagetools inspectshows the registry's manifest list- The Python script parses the JSON to extract the platforms
- The verification confirms that both variants exist in the manifest
- If a platform is missing, the step fails
Exercise 4: An informed decision — do you need multi-platform?
For each scenario, say whether you need multi-platform and why:
- A team of 3 developers, all with a Mac M2, deploying on an AWS EC2
t3.medium(x86) - A team of 5 developers, all with Linux x86, deploying on an AWS Graviton
t4g.medium(ARM) - A public image of a CLI tool that any developer can use
- A personal prototype only you use, a Mac M1, deploying on Render (x86)
See solution
1. A Mac M2 + a deploy on EC2 x86:
- 📋 Recommended but not mandatory. The deploy is x86 → amd64 is enough for production. But the team with M2s benefits from native arm64 images for local development (no Rosetta emulation). If the builds are fast (< 3 min), it's worth it. If they're heavy (torch, cuda), consider amd64 only and have the team use Rosetta.
2. Linux x86 + a deploy on Graviton ARM:
- 📋 Mandatory. Deploying on Graviton requires arm64 images. Without multi-platform, your image doesn't run in production. The team develops on x86, so you need both platforms: amd64 for local development and arm64 for production.
3. A public CLI image:
- 📋 Highly recommended. You don't know what architecture your users have. Apple Silicon is ~50% of the developer laptop market. Linux ARM (Raspberry Pi, ARM servers) is growing. For maximum compatibility, include both platforms.
4. A personal prototype, Mac M1, deploying on Render x86:
- 📋 Not necessary. You're a single user, the deploy is x86. Build amd64 only. If Docker Desktop with Rosetta works fine on your Mac for development, don't invest time in multi-platform for a prototype.
Summary
- ✅ Multi-platform builds the same image for multiple architectures (amd64, arm64)
- ✅ QEMU emulates arm64 on amd64 runners — it works but it's ~2x slower
- ✅ The setup:
docker/setup-qemu-action+platforms: linux/amd64,linux/arm64in the build - ✅
TARGETARCHin the Dockerfile lets you download architecture-specific binaries - ✅ The build time roughly doubles — evaluate whether the benefit justifies it
- ✅ Mandatory if you deploy on ARM (Graviton, Azure ARM)
- ✅ Recommended if you distribute public images or your team uses Apple Silicon
- ✅ Not necessary if you deploy only on x86 and local emulation works fine
- ✅ It's a capability, not an obligation — add it when you need it
Additional resources
- Docker multi-platform builds — Docker's official guide
- docker/setup-qemu-action — The action for installing QEMU
- Buildx automatic platform ARGs — The available platform variables
- AWS Graviton — Getting Started — AWS's ARM instances
- GitHub Actions ARM runners — Native ARM runners
- Docker manifest inspect — How to verify platforms in a manifest