Manylinux and Manyarm Docker Base Images

Every portable geospatial wheel begins inside a controlled container, and within the Modern Python Build Tooling & Wheel Configuration reference this chapter owns one link in that chain: choosing, pinning, and provisioning the manylinux Docker base images that compile GDAL, PROJ, GEOS, PyProj, rasterio, and shapely against a known glibc baseline. The community calls the ARM64 variants “manyarm,” but the official PyPA images unify both architectures under the manylinux_*_aarch64 and manylinux_*_x86_64 tags governed by PEP 600. This page covers the exact image pins to use (quay.io/pypa/manylinux_2_28_x86_64 and its aarch64 sibling), the Dockerfile and cibuildwheel configuration that turns those images into a deterministic build environment, the numbered steps to produce a wheel, and the verification commands that prove the artifact honours the platform contract. The binary-interface rules the resulting .so must satisfy are governed by the Geospatial C-Extension Fundamentals & ABI Architecture reference; the libc decision itself is unpacked in manylinux2014 vs musllinux for spatial libs.

The container is not a packaging detail; it is the sysroot that defines which symbols the compiled extension is allowed to reference. The flow below maps how a source tree becomes a tagged, repaired wheel inside one of these images:

From source tree to a repaired manylinux_2_28 wheel inside a pinned base image A snaking eight-stage pipeline. Stage 1, a source tree of C and Cython files plus a CMakeLists, sits outside the build container. An arrow carries it into a dashed boundary marked as the base image acting as the build sysroot, which fixes the glibc 2.28 symbol ceiling for x86_64 and aarch64. The boundary wraps three stages: stage 2 the pinned manylinux or manyarm base image referenced by sha256 digest, stage 3 a dnf install of proj, geos, and sqlite development libraries, and stage 4 the compile step using an architecture baseline such as x86-64-v2 or neoverse-n1. The compiled extension leaves the sysroot as stage 5, a linked shared object that references the system GDAL, PROJ, and GEOS libraries. Stage 6 runs auditwheel repair to bundle those libraries into a .libs directory and rewrite the load path to $ORIGIN, stage 7 is the resulting wheel tagged manylinux_2_28 for both x86_64 and aarch64, and stage 8 publishes the artifact to a registry. BASE IMAGE = BUILD SYSROOT · glibc 2.28 symbol ceiling · x86_64 + aarch64 Source tree .c / .pyx · CMakeLists Pinned base image manylinux_2_28 @sha256 dnf install proj · geos · sqlite -devel Compile x86-64-v2 / neoverse-n1 Linked .so refs system GDAL/PROJ auditwheel repair bundle .libs · $ORIGIN Tagged wheel x86_64 + aarch64 Registry publish to PyPI 1234 5678 The pinned image is the sysroot: it decides which glibc symbols the .so may reference — repair makes the result relocatable

Prerequisites & Environment

A reproducible geospatial build assumes the toolchain, the base image, and the native library versions are all fixed before the first compiler invocation. Floating any one of them reintroduces the host-drift the container was meant to eliminate.

  • Base images: quay.io/pypa/manylinux_2_28_x86_64 and quay.io/pypa/manylinux_2_28_aarch64, each pinned to a sha256 digest rather than a moving tag. The 2_28 series tracks the AlmaLinux 8 / glibc 2.28 baseline, ships dnf rather than yum, and bundles GCC 12+ and a recent auditwheel.
  • cibuildwheel ≥ 2.21: earlier releases predate the CIBW_MANYLINUX_*_IMAGE overrides and the ubuntu-24.04-arm native runner support this page relies on.
  • auditwheel ≥ 6.1: anything older does not understand the manylinux_2_28 policy file and will mis-tag a wheel that legitimately references glibc-2.28 symbols.
  • Native libraries: GDAL ≥ 3.8, PROJ ≥ 9.3, GEOS ≥ 3.12, SQLite ≥ 3.40. These are either installed into the image with dnf or vendored — the trade-off is decided in vendoring PROJ and GDAL vs system libraries, and this chapter assumes the libraries are present in the build image and bundled at repair time.
  • Reproducible toolchain: lock the compiler and the GDAL/PROJ/GEOS builds through a pixi environment so the libraries you link against in CI are byte-for-byte what a maintainer reproduces locally.
  • Architecture baseline: the 2_28_x86_64 images compile to x86-64-v2 safely (SSE4.2, POPCNT); the aarch64 images target armv8-a. Raising -march/-mcpu beyond the baseline trades portability for instruction-set assumptions the policy does not guarantee.

Resolve and record the digests once so every runner pulls the identical bits:

# Resolve each tag to an immutable digest; commit these to CI config
docker buildx imagetools inspect quay.io/pypa/manylinux_2_28_x86_64  | grep -i digest
docker buildx imagetools inspect quay.io/pypa/manylinux_2_28_aarch64 | grep -i digest

Core Configuration

Two files define the environment: a Dockerfile (when you build a custom image or compile directly) and the cibuildwheel block in pyproject.toml (when an orchestrator manages Python isolation for you). Most spatial projects use both — a thin custom image layered on the official base, driven by cibuildwheel.

The Dockerfile pins the base by digest, installs the spatial dev stack, and sets architecture-aware flags without baking an absolute RPATH:

# Pin to an immutable digest so upstream image updates cannot cause silent ABI
# drift. Replace <digest> with the sha256 resolved from imagetools inspect.
FROM quay.io/pypa/manylinux_2_28_x86_64@sha256:<digest>

# Geospatial build dependencies. proj-devel/geos-devel pull in the headers and
# pkg-config files that scikit-build-core's find_package discovery needs.
RUN dnf install -y \
        gcc gcc-c++ make cmake ninja-build pkgconfig \
        sqlite-devel libcurl-devel zlib-devel \
        libtiff-devel libjpeg-turbo-devel \
        proj-devel geos-devel \
 && dnf clean all

# Architecture-aware optimisation. On the aarch64 image use:
#   ENV CFLAGS="-O2 -fPIC -mcpu=neoverse-n1 -mtune=generic"
ENV CFLAGS="-O2 -fPIC -march=x86-64-v2 -mtune=generic"
ENV CXXFLAGS="-O2 -fPIC -march=x86-64-v2 -mtune=generic"

# Do NOT set an absolute RPATH here. auditwheel repair rewrites RPATH to
# $ORIGIN/.libs when the wheel is produced; an absolute path would defeat that.

The cibuildwheel block points each architecture at its pinned image and feeds the repair step the right exclusions. The wheel.py-api choice — whether to build against the stable ABI with Py_LIMITED_API or a version-specific CPython ABI — is governed by C-API vs CPython ABI compatibility, and the CMake invocation itself is handled by the scikit-build-core backend:

# pyproject.toml
[tool.cibuildwheel]
build = "cp39-* cp310-* cp311-* cp312-*"
build-frontend = "build"

[tool.cibuildwheel.linux]
# Each architecture resolves to its own pinned digest.
manylinux-x86_64-image  = "quay.io/pypa/manylinux_2_28_x86_64@sha256:<x86-digest>"
manylinux-aarch64-image = "quay.io/pypa/manylinux_2_28_aarch64@sha256:<arm-digest>"
before-all = "dnf install -y proj-devel geos-devel sqlite-devel"
# Exclude libs guaranteed by the glibc baseline; bundling them causes collisions.
repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel} --exclude libz.so.1 --exclude libcurl.so.4"

Step-by-Step Implementation

Each step below is a runnable command or config change; together they take a source tree to a verified manylinux_2_28 wheel for both architectures.

  1. Pin both base images by digest. Resolve the tags and store the digests in your pyproject.toml (shown above), never a floating latest or 2_28:

    docker buildx imagetools inspect quay.io/pypa/manylinux_2_28_x86_64 \
      --format '{{json .Manifest.Digest}}'
    
  2. Build (or pull) the spatial build image layered on that digest so the GDAL/PROJ/GEOS headers are present before compilation:

    docker build -t spatial-manylinux:x86_64 -f Dockerfile.manylinux .
    
  3. List the build identifiers cibuildwheel will execute, to confirm the matrix matches your intended Python and architecture set before burning CI minutes:

    pipx run cibuildwheel --print-build-identifiers --platform linux
    
  4. Run the build on a native x86_64 runner. Native runners avoid QEMU emulation entirely, cutting GDAL compile time from tens of minutes to single digits:

    CIBW_BUILD="cp311-manylinux_x86_64" pipx run cibuildwheel --output-dir wheelhouse
    
  5. Run the aarch64 build on a native ARM runner (ubuntu-24.04-arm on GitHub Actions). The manyarm image is selected automatically from your manylinux-aarch64-image pin:

    CIBW_BUILD="cp311-manylinux_aarch64" pipx run cibuildwheel --output-dir wheelhouse
    
  6. Repair runs inside the container via the repair-wheel-command above. If you compile by hand instead of through cibuildwheel, invoke auditwheel explicitly so the spatial libraries are bundled and the tag is stamped:

    auditwheel repair dist/*.whl \
      --plat manylinux_2_28_x86_64 \
      --lib-sdir .libs \
      --exclude libz.so.1 --exclude libcurl.so.4 \
      -w wheelhouse/
    

The /opt/python/*/bin/python interpreters baked into the image are what cibuildwheel iterates over; if you script the build directly, always invoke those interpreters rather than the system python3, which the image deliberately omits from the build path.

Verification

A wheel that compiled is not yet a wheel that is portable. Three checks confirm the platform contract held.

Inspect the platform tag and external library references the wheel still depends on:

auditwheel show wheelhouse/*.whl

Expected output names the policy the wheel satisfies and lists only baseline libraries as external:

your_pkg-...-cp311-cp311-manylinux_2_28_x86_64.whl is consistent with the
following platform tag: "manylinux_2_28_x86_64".

The wheel references external versioned symbols in these system-provided
shared libraries: libc.so.6, libm.so.6, libpthread.so.0

Confirm the bundled spatial libraries resolve through $ORIGIN and nothing falls back to a host path — the resolution mechanics are detailed in managing shared library paths in manylinux:

unzip -o wheelhouse/*.whl -d /tmp/whl
ldd /tmp/whl/your_pkg/_core.cpython-311-x86_64-linux-gnu.so
# libgdal.so.34 => /tmp/whl/your_pkg/.libs/libgdal-... (bundled, good)
# any "=> not found" line is a repair failure

Finally, install into a clean container that has none of the build dependencies and import the package — the definitive proof the wheel is self-contained:

docker run --rm -v "$PWD/wheelhouse:/w" python:3.11-slim \
  bash -c "pip install /w/*x86_64.whl && python -c 'import your_pkg, pyproj; print(pyproj.proj_version_str)'"

What the Image Actually Guarantees

A manylinux image is not simply “a Linux with old glibc”. It is a curated build environment whose properties are what make the resulting tag meaningful, and knowing which properties matter explains why building outside one produces wheels that do not install.

The four properties a manylinux image provides and why each matters Four properties. An old glibc with a new compiler lets the build use modern C++ while keeping a low symbol floor. A curated allow-list of system libraries defines what a wheel may reference externally. Multiple CPython interpreters are pre-installed so the same image serves every target. The auditwheel policy files that define each tag ship inside the image, so the tag computation matches the environment the wheel was built in. old glibc, new compiler GCC 13 or newer, but linked against a glibc from years earlier, via a devtoolset-style toolchain this is the trick that makes a C++17 GDAL build installable on distributions released before C++17 a curated external allow-list libc, libm, libpthread, libdl, libstdc++, libgcc_s and a short list beyond anything else your wheel references must be bundled — this list is what auditwheel enforces every CPython, pre-installed /opt/python/cp39-cp39, cp310, cp311, cp312 … one image builds every interpreter target, which matters much less once you build a single abi3 wheel the policy definitions themselves auditwheel's policy files ship inside the image so the tag computed at repair time matches the environment the objects were actually built in

The first box is the one that is genuinely hard to reproduce outside the image. Compiling modern C++ while emitting references only to an old glibc requires a toolchain deliberately assembled for it; an ordinary old distribution gives you the old glibc and an old compiler, which a current GDAL will not build with.

Choosing and Pinning an Image

Two decisions follow: which policy level to target, and how to refer to the image so that the choice stays stable.

Referring to a base image by floating tag versus by digest Two ways of naming the same image. A floating tag such as latest resolves to whatever was most recently published, so the toolchain can change between two runs with no change in your repository, silently invalidating cached objects and occasionally changing the computed platform tag. A digest names exact bytes, so the toolchain is fixed until you deliberately move it, and cache keys derived from it stay meaningful. floating tag manylinux_2_28_x86_64:latest the toolchain can change between two runs with no change in your repository cached native objects silently pair with a different compiler — link errors that look random and the computed platform tag can move digest …_x86_64@sha256:9f3c… exact bytes; the toolchain is fixed until you deliberately update the digest cache keys derived from it stay meaningful, and a toolchain change appears as a diff update it on a schedule, reviewed like a dependency

Pinning by digest and bumping it deliberately — ideally on an automated schedule that opens a pull request — turns base-image drift from an invisible source of intermittent failure into an ordinary, reviewable dependency update.

Optimization & Edge Cases

Prefer native runners over QEMU. Emulated aarch64 builds of GDAL can run 5–10× slower than native and occasionally crash the emulator on large C++ translation units. Use ubuntu-24.04-arm for the ARM leg and reserve QEMU only for architectures with no native runner. The matrix below keeps each architecture on hardware that matches it:

strategy:
  fail-fast: false
  matrix:
    # ubuntu-latest -> x86_64, ubuntu-24.04-arm -> aarch64; no QEMU needed.
    os: [ubuntu-latest, ubuntu-24.04-arm]
    python-version: ["3.9", "3.10", "3.11", "3.12"]

Cache the compiled native libraries, not just pip wheels. Recompiling PROJ and GDAL on every matrix cell dominates wall-clock time. Persist the dnf-installed RPMs or a prebuilt .a/.so cache through CIBW_CACHE_PATH and your CI cache key; the broader strategy lives in async build execution and cache strategies.

Choosing 2_28 vs 2014. The manylinux2014 (glibc 2.17 / CentOS 7) image still installs on older enterprise hosts, but its GCC 10 toolchain struggles with the C++17 that modern GEOS and GDAL require, and it lacks the AVX2-friendly baseline. Use manylinux_2_28 unless you have a concrete target stuck on glibc 2.17. The full musl-versus-glibc analysis — including why musllinux silently breaks PROJ thread-local destructors — is covered in manylinux2014 vs musllinux for spatial libs.

Prune the build matrix. PyPy and free-threaded builds of GDAL rarely have downstream demand; restrict CIBW_BUILD to the CPython ABIs you actually publish, and gate aarch64 on main/release branches if PR runs are saturating ARM runner capacity.

Bundle layout matters downstream. The .libs directory and its $ORIGIN RPATH are what makes the wheel relocatable; how those files are staged and named feeds directly into build artifact structuring and packaging.

Troubleshooting

auditwheel rejects the wheel as too new for the requested tag.

auditwheel.error.NonPlatformWheel: ... cannot be repaired to the
"manylinux2014_x86_64" platform: the wheel references symbols from
glibc 2.28 (GLIBC_2.28) which are not available in this policy

The extension was compiled inside the 2_28 image but repaired against the 2014 policy. Either repair with --plat manylinux_2_28_x86_64, or, if you genuinely need glibc 2.17 compatibility, build inside the manylinux2014 image so no 2.28 symbols are ever referenced.

Driver discovery fails at import despite a clean repair.

OSError: PROJ: proj_create_from_database: Cannot find proj.db

auditwheel bundles libproj.so but not the proj.db data file, so the runtime cannot locate the projection database. Package the PROJ data inside the wheel and point PROJ_DATA (or pyproj.datadir.set_data_dir) at it; this is the data-path half of the vendoring decision in vendoring PROJ and GDAL vs system libraries.

The wheel imports on the build image but not on the user’s host.

ImportError: libgdal.so.34: cannot open shared object file: No such file or directory

The library was resolved from the build image at link time but never bundled, because an over-broad --exclude removed it. Drop the --exclude libgdal* argument so auditwheel copies libgdal into .libs/, and re-verify with ldd that no spatial library shows => not found.

aarch64 build hangs or segfaults under emulation.

qemu: uncaught target signal 11 (Segmentation fault) - core dumped

QEMU user-mode emulation cannot reliably compile large GDAL translation units. Move the aarch64 leg to a native ubuntu-24.04-arm runner; if a native runner is unavailable, raise the container memory limit and split the GDAL compile into smaller units, but native hardware is the durable fix.

Frequently Asked Questions

Can I build manylinux wheels without a manylinux image?

Technically yes, and in practice it goes wrong. The image is what supplies a modern compiler linked against an old glibc, which is the combination that lets a C++17 GDAL build install on older distributions. Building on an ordinary old distribution gives you the old glibc and an old compiler; building on a current one gives you a glibc floor far higher than you intended.

Which policy level should a new spatial project target?

manylinux_2_28 for most projects: it covers every current distribution and container base, and its toolchain builds modern GDAL without argument. Target manylinux2014 only if you have users on older enterprise Linux, and expect to fight the older compiler when doing so.

Should the image be pinned by tag or by digest?

By digest. A floating tag can move between two runs with no change in your repository, silently pairing cached objects with a different toolchain and occasionally changing the computed platform tag. Pinning by digest and bumping it through a scheduled pull request turns that drift into an ordinary, reviewable dependency update.

Does the aarch64 image differ in any way that matters?

Only in that fewer things assume it. The policy, the tooling and the layout are the same, but upstream build scripts occasionally hard-code x86_64 assumptions, and emulated builds are slow enough to change how you structure the pipeline. Neither is a property of the image; both are worth budgeting for.

Is it reasonable to build a custom image on top of the official one?

Yes, and for a GDAL-heavy project it is usually the right move. Adding a compiled GDAL, PROJ and GEOS layer to the official image keeps every guarantee the base provides while removing the slowest step from the per-commit path. Build it in its own pipeline, publish it by digest, and treat the digest as a pinned dependency.