Build Multi-Architecture Django Docker Images

Build Django images for both amd64 and arm64 (Graviton, Ampere, Apple Silicon) with docker buildx: QEMU versus native runners, wheels, caching in GitHub Actions, and verifying manifests.

Problem statement

ARM64 servers (AWS Graviton, Hetzner and Oracle Ampere instances, and others) are often cheaper per unit of performance than x86 machines, and developers on Apple Silicon laptops build ARM images by default. A Django image built for one architecture fails on the other with exec format error, or runs slowly under emulation.

A multi-architecture image contains a variant for each platform under one tag; Docker pulls the right one automatically. This guide builds such images for Django, handles Python packages with native extensions, and keeps CI build times reasonable.

One tag, two architecturesDockerfilesame sourcebuildxamd64 and arm64Manifest listmyapp:1.4.0Each hostpulls its platformA manifest list maps one tag to per-platform images; hosts pull only the variant they need.
  1. Use docker buildx with a builder that supports multiple platforms.
  2. Build with --platform linux/amd64,linux/arm64 --push; multi-platform results must go to a registry (or a store that supports them).
  3. Prefer base images and Python packages with prebuilt wheels for both architectures (python:3.12-slim, psycopg[binary] or build dependencies for psycopg[c]).
  4. In GitHub Actions, use setup-qemu-action, setup-buildx-action, and build-push-action with platforms:; for faster builds, use native ARM runners and merge the results.
  5. Cache layers (for example type=gha) to avoid rebuilding dependencies on every run.
  6. Verify with docker buildx imagetools inspect.

Step-by-step solution

1. Make the Dockerfile architecture-neutral

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN DJANGO_SETTINGS_MODULE=config.settings.build python manage.py collectstatic --noinput
USER nobody
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

Official Python images are published for both platforms, so FROM python:3.12-slim resolves to the right variant automatically. Things that break multi-arch builds:

  • Downloading architecture-specific binaries (for example a CLI tool) with a hardcoded amd64 URL. Use the TARGETARCH build argument:
ARG TARGETARCH
RUN curl -fsSL -o /usr/local/bin/tool "https://example.com/tool-linux-${TARGETARCH}" && chmod +x /usr/local/bin/tool
  • Packages without ARM wheels, which then compile from source. Most mainstream Django dependencies ship manylinux wheels for aarch64. If a package compiles, add the build tools in a builder stage and copy the installed packages into the runtime stage.

2. Build locally

docker buildx create --name multi --use
docker buildx inspect --bootstrap
docker buildx build --platform linux/amd64,linux/arm64 \
  -t ghcr.io/example/myapp:1.4.0 --push .

If the builder cannot run the foreign platform, register QEMU emulators first:

docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64

A plain docker build (or --load) produces a single-platform image for the local architecture. Multi-platform builds are pushed to a registry, or loaded into a local image store only when your Docker setup uses the containerd image store.

3. Build in GitHub Actions with QEMU

# .github/workflows/image.yml
name: image
on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write }
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ghcr.io/example/myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

QEMU emulation is simple but slow for CPU-heavy steps: compiling C extensions or running collectstatic with heavy post-processing can take several times longer under emulation.

4. Faster: native runners and a merged manifest

Build each platform on a native runner, push by digest, then combine:

jobs:
  build:
    strategy:
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-24.04
          - platform: linux/arm64
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    steps:
      # checkout, setup-buildx, login as above
      - id: build
        uses: docker/build-push-action@v6
        with:
          platforms: ${{ matrix.platform }}
          outputs: type=image,name=ghcr.io/example/myapp,push-by-digest=true,name-canonical=true,push=true
      - run: echo "${{ steps.build.outputs.digest }}" > digest-${{ strategy.job-index }}.txt
      - uses: actions/upload-artifact@v4
        with: { name: "digest-${{ strategy.job-index }}", path: "digest-*.txt" }

  merge:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { pattern: "digest-*", merge-multiple: true }
      # login as above
      - run: |
          docker buildx imagetools create -t ghcr.io/example/myapp:${{ github.sha }} \
            $(for f in digest-*.txt; do echo "ghcr.io/example/myapp@$(cat $f)"; done)
emulated or native buildsQEMU on one runnerNative runner per archsetup✓two setup actionsmatrix + merge jobbuild speed!several times slower✓native speedC extensions✕compiles under emulation✓compiles nativelyworkflow shapeone jobbuild by digest, then mergeStart with QEMU; switch when the ARM build becomes the slow stepdjango-deployment.com
The merge job joins per-platform digests into one tag with imagetools create.

Check the runner labels available to your account; ARM runners for public and private repositories have different availability.

5. Verify the result

docker buildx imagetools inspect ghcr.io/example/myapp:1.4.0

Expect a manifest list with entries for linux/amd64 and linux/arm64. Test both variants actually run your app:

docker run --rm --platform linux/arm64 ghcr.io/example/myapp:1.4.0 python -c "import django, psycopg; print(django.get_version())"
docker run --rm --platform linux/amd64 ghcr.io/example/myapp:1.4.0 python manage.py check

Verification check:

  • imagetools inspect lists both platforms.
  • On an ARM server, docker image inspect --format '{{.Architecture}}' ghcr.io/example/myapp:1.4.0 prints arm64.
  • The app passes manage.py check --deploy on both.

Explanation

A multi-arch tag points to an OCI image index (manifest list) that references one image per platform. When a host pulls the tag, the registry client selects the image matching the host's OS and CPU. buildx builds each variant, either natively or through QEMU emulation, and pushes them together with the index. Because the Dockerfile is shared, the only architecture-specific parts are binaries you download and packages that must be compiled, which is where most multi-arch problems come from.

what the error usually meansSymptomCause and fixexec format error on the serverimage built for the otherCPU; push a multi-arch tagpip builds for twenty minutesno aarch64 wheel, compilingunder QEMU; use a builderstage or native runnerdownloaded tool fails on ARMhardcoded amd64 URL; use TARGETARCH--load refuses a multi-platform buildimage store cannot hold it;push or build one platformMost failures trace back to a binary or wheel for only one architecturedjango-deployment.com
Check the architecture of every binary the Dockerfile downloads, not only the base image.

Edge cases / notes

Performance differences

Benchmark your app on ARM before switching production; most pure-Python and database-bound Django workloads perform well, but some native libraries have less optimised ARM builds.

Local development

Apple Silicon developers building images for x86 servers should build with --platform linux/amd64 explicitly or let CI build release images. Pushing an ARM-only image from a laptop is a classic cause of exec format error in production.

When to automate this

Build release images only in CI, never from laptops, and tag them with the commit SHA. A post-build job that runs manage.py check inside each platform variant catches architecture-specific dependency problems before deploy.

Next steps

FAQ

What does "exec format error" mean for a Django container?

The image was built for a different CPU architecture than the host. Build a multi-arch image or one for the host's platform.

Can Django run on ARM64 servers?

Yes. Python, Django, and common dependencies such as psycopg, Pillow, and cryptography provide ARM64 wheels.

Why is my multi-arch build so slow in CI?

The non-native platform is built under QEMU emulation. Use native ARM runners per platform and merge the results into one tag.

Can I load a multi-platform image into local Docker?

Only with an image store that supports multi-platform images, such as Docker's containerd image store. Otherwise push to a registry or build one platform with --load.

Learning path · step 10 of 12

Docker, from image to production

Keep going