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)'"

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.