How to Build a Small, Fast Python GIS Docker Image
Problem statement
The obvious Dockerfile produces something absurd:
FROM python:3.12
RUN pip install geopandas rasterio
COPY . /app
$ docker build -t gis . && docker images gis
REPOSITORY TAG SIZE
gis latest 2.41GB
Two and a half gigabytes to run a script that clips some polygons. It takes four minutes to build, ninety seconds to push, and every code change rebuilds the whole thing because COPY . /app invalidates nothing useful β except that it does, since it sits after the install and any file change busts the cache below it.
Try to shrink it with Alpine and it stops working entirely:
FROM python:3.12-alpine
RUN pip install geopandas
error: Microsoft Visual C++ 14.0 or greater is required
... building 'shapely' ... fatal error: geos_c.h: No such file or directory
The size and the build failure have the same root cause: Python GIS is a thin layer over large C libraries, and how you obtain those libraries decides everything.
Quick answer
Start from a base image that already has GDAL, and use a multi-stage build:
# βββ build stage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2 AS build
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# βββ runtime stage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 proj-data \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 app
COPY /venv /venv
ENV PATH="/venv/bin:$PATH" \
PROJ_NETWORK=OFF \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY src/ ./src/
USER app
ENTRYPOINT ["python", "-m", "src.pipeline"]
gis latest 612MB (from 2.41GB)
| Change | Saving |
|---|---|
| a GDAL base instead of compiling | build works at all; ~400 MB |
| multi-stage β no compilers in the runtime | ~700 MB |
--no-install-recommends and cleaning apt lists |
~150 MB |
--no-cache-dir on pip |
~200 MB |
| ordering layers so code changes rebuild one layer | minutes per build |
Step-by-step solution
1. Choose the base image deliberately
| Base | Result | Verdict |
|---|---|---|
python:3.12 |
works, ~2.4 GB | too big; ships a full toolchain |
python:3.12-slim |
works via wheels, ~1.1 GB | acceptable, and see the caveat below |
python:3.12-alpine |
compiles from source, slowly, or fails | avoid |
ghcr.io/osgeo/gdal:ubuntu-small-* |
GDAL, GEOS, PROJ preinstalled | the default choice |
mambaorg/micromamba |
conda-forge stack, ~900 MB | good when you need conda pinning |
Alpine is the trap. It uses musl libc instead of glibc, and PyPI's manylinux wheels are built for glibc. So pip install shapely on Alpine cannot use a wheel and falls back to compiling from source, which needs GEOS headers, a C compiler, and twenty minutes β and often fails. The image that was supposed to be small ends up bigger than the Debian one once the toolchain is installed.
The python:3.12-slim route works, because shapely, pyproj, rasterio and fiona all publish manylinux wheels with their native libraries bundled. The caveat is that each wheel bundles its own copy, so an image can carry several GDAL and GEOS builds β wasteful, and the source of the version mismatch described in reproducible GIS environments.
A GDAL base image supplies one tested native stack for everything to share.
2. Use a multi-stage build
The build needs compilers, headers and pip's machinery. The runtime needs none of it:
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip python3-venv build-essential python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
RUN apt-get update && apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/*
COPY /venv /venv
ENV PATH="/venv/bin:$PATH"
Installing into a virtualenv is what makes the copy clean: /venv is one self-contained directory holding every installed package, so COPY --from=build /venv /venv moves the whole environment in one instruction. Copying site-packages instead misses console scripts and any data files installed elsewhere.
build-essential and python3-dev are around 400 MB and appear only in the build stage, so they never reach the final image.
3. Order layers so a code change rebuilds one layer
Docker caches per instruction and invalidates everything after the first change. So the order is: rarely-changing first, frequently-changing last.
# β any source change reinstalls every dependency
COPY . /app
RUN pip install -r /app/requirements.txt
# β
dependencies are cached until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
$ time docker build -t gis . # first build
real 3m41.2s
$ touch src/pipeline.py && time docker build -t gis .
real 0m2.8s # only the COPY and below re-run
Three and a half minutes to three seconds. This is the highest-value change in the whole article for day-to-day work, and it costs one line of reordering.
Add a .dockerignore, or the build context includes everything:
.git
.venv
__pycache__/
*.pyc
data/
notebooks/
tests/fixtures/*.gpkg
.pytest_cache
*.egg-info
Without it, COPY sends your entire data/ directory to the daemon on every build and β worse β a change to any ignored file busts the cache.
4. Strip what you do not need
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 proj-data \
&& rm -rf /var/lib/apt/lists/*
Each one earns its place:
--no-install-recommendsstops apt pulling in documentation, locales and suggested packages. Typically 100β200 MB.rm -rf /var/lib/apt/lists/*in the sameRUNβ a separateRUNwould not shrink the image, because the earlier layer still contains the files.PIP_NO_CACHE_DIR=1keeps pip's wheel cache out of the layer, around 200 MB for this stack.PYTHONDONTWRITEBYTECODE=1skips.pycfiles, which will be regenerated at runtime anyway.
Check where the size actually is before optimising further:
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" gis:latest | head -12
612MB FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
198MB RUN pip install --no-cache-dir -r requirements.txt
44MB RUN apt-get install python3 proj-data
2MB COPY src/ ./src/
dive gis:latest gives an interactive version and shows wasted space from files added and later deleted.
5. Keep the native stack deterministic
An image is only worth building if it behaves the same every time:
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2@sha256:1f9c4e0a...
RUN apt-get update && apt-get install -y --no-install-recommends proj-data \
&& rm -rf /var/lib/apt/lists/*
ENV PROJ_NETWORK=OFF
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes -r requirements.txt
- The digest pins the base image byte for byte. A tag such as
3.9.2can be re-pushed; asha256:cannot. proj-datainstalls the full set of transformation grids, so PROJ never needs to fetch one.PROJ_NETWORK=OFFmakes a missing grid an error rather than a silent download that changes your coordinates by millimetres.--require-hashesmakes pip refuse any package whose content differs from the lockfile.
6. Run as a non-root user
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY src/ ./src/
USER app
Containers run as root by default. A mounted volume then produces root-owned output files on the host, which is a daily annoyance, and a compromised process has more privilege than it needs.
Set --uid explicitly rather than letting it be allocated. Kubernetes runAsUser policies and volume permissions both reference the numeric id, and one that drifts between builds breaks them.
--chown on the COPY avoids a separate RUN chown, which would duplicate every copied file in a new layer.
Code examples
Example 1: a complete production Dockerfile
# syntax=docker/dockerfile:1.7
ARG GDAL_IMAGE=ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
# βββ build ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM ${GDAL_IMAGE} AS build
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1 \
DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip python3-venv python3-dev build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN \
pip install --require-hashes -r requirements.txt
# βββ test (optional stage, not in the final image) ββββββββββββββββββββββββββ
FROM build AS test
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
COPY src/ ./src/
COPY tests/ ./tests/
RUN pytest -m "not slow" -q
# βββ runtime ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FROM ${GDAL_IMAGE} AS runtime
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 proj-data \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 --shell /usr/sbin/nologin app
COPY /venv /venv
ENV PATH="/venv/bin:$PATH" \
PROJ_NETWORK=OFF \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
GDAL_CACHEMAX=512 \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.gpkg,.parquet"
WORKDIR /app
COPY src/ ./src/
USER app
HEALTHCHECK \
CMD python -c "import geopandas, rasterio; print('ok')" || exit 1
ENTRYPOINT ["python", "-m", "src.pipeline"]
CMD ["--help"]
docker build --target test -t gis:test . # runs the tests, builds nothing else
docker build -t gis:latest . # the runtime image
Several details are worth pulling out.
--mount=type=cache,target=/root/.cache/pip uses BuildKit's cache mount: pip's downloads persist between builds without ever entering a layer, so rebuilds are fast and the image stays small. The older trade-off between those two disappears.
The test stage runs the suite during the build and is not part of the runtime image, so a failing test fails the build. --target test builds only up to that stage.
The three GDAL environment variables matter for real workloads. GDAL_CACHEMAX=512 caps GDAL's block cache in MB β the default is a percentage of system RAM, which in a memory-limited container gets the process killed. GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR stops GDAL listing an entire directory when opening one file, which is slow on network storage and catastrophic on object storage. And CPL_VSIL_CURL_ALLOWED_EXTENSIONS cuts the number of HTTP requests when reading remote files.
Example 2: measuring the difference
#!/usr/bin/env bash
# compare-images.sh β build several variants and report size and build time
set -euo pipefail
variants=(naive slim gdal multistage)
for v in "${variants[@]}"; do
start=$(date +%s)
docker build --no-cache -f "Dockerfile.$v" -t "gis:$v" . > "/tmp/build-$v.log" 2>&1
elapsed=$(( $(date +%s) - start ))
size=$(docker image inspect "gis:$v" --format '{{.Size}}')
layers=$(docker image inspect "gis:$v" --format '{{len .RootFS.Layers}}')
printf "%-12s %8.0f MB %4d s %2d layers\n" \
"$v" "$(echo "$size / 1048576" | bc -l)" "$elapsed" "$layers"
done
echo
echo "incremental rebuild after a source change:"
touch src/pipeline.py
for v in "${variants[@]}"; do
start=$(date +%s)
docker build -f "Dockerfile.$v" -t "gis:$v" . > /dev/null 2>&1
printf "%-12s %4d s\n" "$v" "$(( $(date +%s) - start ))"
done
naive 2412 MB 221 s 14 layers
slim 1104 MB 186 s 12 layers
gdal 884 MB 94 s 11 layers
multistage 612 MB 112 s 9 layers
incremental rebuild after a source change:
naive 198 s
slim 172 s
gdal 88 s
multistage 3 s
Two separate wins, and they matter to different people. The 4Γ size reduction matters for pushing, pulling and storage. The 66Γ incremental rebuild matters to whoever is developing, every day, and comes entirely from layer ordering rather than from anything about size.
--no-cache on the first loop is what makes the cold-build times comparable; without it Docker reuses layers and the numbers mean nothing.
Example 3: verifying the image before shipping it
# tests/test_image.py β run against a built image
import json
import subprocess
import pytest
IMAGE = "gis:latest"
def run(cmd, image=IMAGE):
result = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "python", image, "-c", cmd],
capture_output=True, text=True, timeout=120)
if result.returncode != 0:
pytest.fail(f"command failed:\n{result.stderr}")
return result.stdout.strip()
def test_native_stack_versions_are_expected():
out = run(
"import json,geopandas,pyproj,rasterio;"
"from shapely import geos_version_string;"
"print(json.dumps({'gpd':geopandas.__version__,"
"'geos':geos_version_string.split('-')[0],"
"'proj':pyproj.proj_version_str,'gdal':rasterio.__gdal_version__}))")
v = json.loads(out)
assert v["gpd"].startswith("1.0")
assert v["gdal"].startswith("3.9")
assert v["proj"].startswith("9.")
def test_proj_network_is_off_and_grids_are_present():
out = run("import pyproj;"
"from pyproj import Transformer;"
"t=Transformer.from_crs(4326,27700,always_xy=True);"
"print(pyproj.network.is_network_enabled(), t.transform(-2.2426,53.4808))")
enabled, coords = out.split(" ", 1)
assert enabled == "False", "PROJ_NETWORK must be off for reproducible transforms"
x, y = eval(coords)
assert abs(x - 383_618.507) < 0.01, "OSTN15 grid appears to be missing"
def test_runs_as_non_root():
out = subprocess.run(["docker", "run", "--rm", "--entrypoint", "id", IMAGE, "-u"],
capture_output=True, text=True).stdout.strip()
assert out != "0", "the image must not run as root"
assert out == "10001"
def test_writes_are_possible_to_a_mounted_volume(tmp_path):
result = subprocess.run(
["docker", "run", "--rm", "-v", f"{tmp_path}:/out",
"--entrypoint", "python", IMAGE, "-c",
"import geopandas as gpd; from shapely.geometry import box;"
"gpd.GeoDataFrame({'id':[1]},geometry=[box(0,0,1,1)],crs=27700)"
".to_file('/out/t.gpkg',driver='GPKG')"],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert (tmp_path / "t.gpkg").exists()
def test_image_is_not_absurdly_large():
size = int(subprocess.run(
["docker", "image", "inspect", IMAGE, "--format", "{{.Size}}"],
capture_output=True, text=True).stdout)
assert size < 900 * 1024 ** 2, f"image is {size / 1024**2:.0f} MB"
The second test is the valuable one. It asserts a transformed coordinate, so a missing proj-data package β which produces no error, just a less accurate transformation β fails the build. Asserting library versions would not catch it, because the versions are fine; only the grid data is absent.
The volume-write test catches the other silent problem: a non-root user that cannot write to a mounted directory. That failure appears only at runtime, on the machine that mounts the volume, usually at an inconvenient hour.
The size assertion is a ratchet. It does not make the image small, but it stops it quietly growing back.
Explanation
A Python GIS image is large for a specific and unavoidable reason: the dependency graph terminates in C libraries, not in Python. GEOS is a computational geometry engine, PROJ carries a database of coordinate systems and hundreds of transformation grids, and GDAL supports around 200 formats and links against libraries for each. Together they are several hundred megabytes before any Python is installed. There is no version of this stack that fits in 50 MB.
That fact explains the base-image decision. The naive image is large because python:3.12 ships a complete build toolchain β compilers, headers, development libraries β none of which is needed to run anything. Alpine fails because musl libc is incompatible with PyPI's glibc-targeted manylinux wheels, so every native package must compile from source, which needs the toolchain Alpine was chosen to avoid. A GDAL base image is the honest answer: someone has already built and tested the native stack, and you inherit it rather than reconstructing it.
Multi-stage builds work because build-time and run-time dependencies barely overlap. Compiling or installing needs gcc, python3-dev, header packages and pip's cache; executing needs the compiled artefacts and the shared libraries they link against. A stage boundary lets you keep only the second set. Installing into a virtualenv is what makes the boundary crossable in one instruction, since /venv is a single self-contained tree.
Layer caching is a separate concern from size, and often the more valuable one. Docker caches per instruction and invalidates every layer after the first change, so an instruction's position determines how often it re-runs. Dependencies change monthly; source changes hourly. Putting COPY requirements.txt and the install above COPY src/ means a source edit re-runs one cheap instruction instead of a four-minute install. The measurement in Example 2 β three seconds against three minutes β is entirely this.
The GDAL environment variables deserve more attention than they get. GDAL_CACHEMAX defaults to a fraction of system memory, and GDAL reads the host's memory, not the container's limit. In a container capped at 2 GB on a 64 GB host, GDAL will happily size its cache for 64 GB and be killed by the OOM killer with no useful message. Setting it explicitly is the difference between a working container and an unexplained exit code 137.
Finally, an image is a reproducibility artefact or it is nothing. Its whole purpose is that the same bytes run the same way anywhere. That property is undone by a mutable base tag, an unpinned apt-get install, or PROJ_NETWORK=ON quietly fetching a grid at runtime. Pinning the base by digest, hash-pinning the Python requirements, and installing proj-data are what make the image a fixed thing rather than a recipe that resolves differently each time β the argument developed in reproducible GIS environments explained.
Edge cases or notes
- Alpine forces source builds of shapely, pyproj, rasterio and fiona. Use a Debian or Ubuntu base.
rm -rf /var/lib/apt/lists/*must be in the sameRUNas theapt-get install, or the files remain in the earlier layer.GDAL_CACHEMAXreads host memory, not the container limit. Set it explicitly or risk an OOM kill.--mount=type=cacheneeds BuildKit and the# syntax=docker/dockerfile:1.7line.- Pin the base image by digest for scheduled work; a tag can be re-pushed.
proj-datais separate from PROJ. Without it,PROJ_NETWORK=OFFdegrades transformation accuracy silently.- Give the non-root user a fixed numeric uid, since volume permissions and Kubernetes policies reference the number.
.dockerignoreaffects the cache, not just the context size β an ignored file changing will not bust it.docker historyanddiveshow where the size actually is, which is usually not where you expect.- Multi-architecture builds need
docker buildx build --platform linux/amd64,linux/arm64; not all GDAL bases publish both.
Internal links
- How to containerise a Python GIS pipeline with Docker β the wider practice
- Reproducible GIS environments explained β why the native stack must be pinned
- GDAL and GeoPandas fail inside Docker β diagnosing a build that will not work
- How to set up a Python GIS environment that actually works β the same problem outside a container
- How to run a Python GIS pipeline in CI with GitHub Actions β building the image in CI
- GIS tests run out of memory or time out in CI β where
GDAL_CACHEMAXmatters - What GDAL and OGR actually are β what all this weight is
- How to schedule a Python GIS script to run automatically β running the image
FAQ
Why is my Python GIS image so large?
Because GEOS, PROJ and GDAL are several hundred megabytes of C libraries, and a python:3.12 base adds a full build toolchain on top. A GDAL base plus a multi-stage build typically gets you from 2.4 GB to about 600 MB.
Can I use Alpine to make it smaller?
No. Alpine uses musl libc and PyPI's wheels target glibc, so every native package compiles from source β slowly, often unsuccessfully, and the toolchain needed makes the image larger than the Debian one.
What does a multi-stage build actually save?
The compilers, headers and development packages needed to install but not to run β around 700 MB. Install into a virtualenv so the whole environment copies across in one instruction.
Why is my rebuild so slow after a one-line code change?
COPY . /app sits above the dependency install, so any file change invalidates it. Copy requirements.txt and install first, then copy the source.
What is GDAL_CACHEMAX and why set it?
GDAL's block cache size in MB. It defaults to a fraction of host memory, which in a memory-limited container leads to the process being OOM-killed. Set it to something the limit can accommodate.
Do I need proj-data?
If coordinate accuracy matters, yes. Without the transformation grids PROJ falls back to a less accurate method β silently, unless PROJ_NETWORK is off and the grid is genuinely required.
Should I pin the base image by digest?
For anything scheduled or shared, yes. A tag such as 3.9.2 can be re-pushed with different content; a sha256: digest cannot.