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:
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_64andquay.io/pypa/manylinux_2_28_aarch64, each pinned to asha256digest rather than a moving tag. The2_28series tracks the AlmaLinux 8 / glibc 2.28 baseline, shipsdnfrather thanyum, and bundles GCC 12+ and a recentauditwheel. cibuildwheel≥ 2.21: earlier releases predate theCIBW_MANYLINUX_*_IMAGEoverrides and theubuntu-24.04-armnative runner support this page relies on.auditwheel≥ 6.1: anything older does not understand themanylinux_2_28policy 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
dnfor 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_64images compile tox86-64-v2safely (SSE4.2, POPCNT); theaarch64images targetarmv8-a. Raising-march/-mcpubeyond 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.
-
Pin both base images by digest. Resolve the tags and store the digests in your
pyproject.toml(shown above), never a floatinglatestor2_28:docker buildx imagetools inspect quay.io/pypa/manylinux_2_28_x86_64 \ --format '{{json .Manifest.Digest}}' -
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 . -
List the build identifiers
cibuildwheelwill execute, to confirm the matrix matches your intended Python and architecture set before burning CI minutes:pipx run cibuildwheel --print-build-identifiers --platform linux -
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 -
Run the aarch64 build on a native ARM runner (
ubuntu-24.04-armon GitHub Actions). Themanyarmimage is selected automatically from yourmanylinux-aarch64-imagepin:CIBW_BUILD="cp311-manylinux_aarch64" pipx run cibuildwheel --output-dir wheelhouse -
Repair runs inside the container via the
repair-wheel-commandabove. If you compile by hand instead of throughcibuildwheel, invokeauditwheelexplicitly 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.
Related
- Modern Python Build Tooling & Wheel Configuration — the parent reference covering the full build-frontend-to-registry pipeline these base images sit inside.
- manylinux2014 vs musllinux for spatial libs — the libc decision matrix and recovery steps when a musl target breaks GDAL/PROJ.
- Integrating CMake with scikit-build-core — the backend that drives the compile step that runs inside these containers.
- Managing shared library paths in manylinux — how the
$ORIGINRPATH and.libsdirectory make the repaired wheel relocatable. - Async build execution and cache strategies — caching the compiled GDAL/PROJ artifacts so the matrix does not recompile them on every run.