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)'"
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 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.
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.
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.
- cibuildwheel vs manual Docker matrix for GDAL wheels — when to let the managed tool drive these images versus scripting the matrix yourself.
- 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.