Containerizing Geospatial Python Environments

A geospatial Python stack is unusually sensitive to its environment. rasterio sits on GDAL, GDAL sits on PROJ, and PROJ consults a database of coordinate operations and, sometimes, downloadable grid files. Change any of those and the same script can produce a different answer — not an error, a different answer. Containers are how that stack is made reproducible, and this topic covers building them well, inside the wider context of Cloud Execution & Orchestration.

The problem is not “how do I write a Dockerfile”. It is that the obvious Dockerfile produces a two-gigabyte image that takes forty seconds to pull, contains a compiler toolchain nobody needs at runtime, and pins nothing below the Python layer — so the image built in March and the image built in September behave differently.

Prerequisites

pip install "rasterio>=1.3.0" "pyproj>=3.4" "rioxarray>=0.15"
Component Version Why it matters
Docker or Podman ≥24 Multi-stage builds and BuildKit caching
Python base image 3.11-slim Small, current, wheel-compatible
rasterio wheels ≥1.3.0 Bundle GDAL and PROJ, removing the system dependency
GDAL (if system) ≥3.6 Needed only when the wheels do not cover a driver you use

Conceptually you need the read-path settings from How to Read COG Headers Without Downloading Full Files and the job model from Processing COGs on AWS Batch with Docker, because both decide what the image has to contain.

The two ways to get GDAL into an image

Almost every geospatial Dockerfile is one of two shapes, and choosing between them early saves a rebuild later.

Wheels or system GDAL Installing rasterio from binary wheels onto a slim Python base gives a small image where GDAL and PROJ are pinned by the wheel. Starting from a system GDAL image gives access to the command-line tools and every driver, at the cost of a much larger image and a looser coupling between the Python package and the library beneath it. wheels on python:3.11-slim system GDAL base image your pipeline code rasterio / rioxarray wheels GDAL + PROJ bundled inside the wheel python:3.11-slim · ≈ 150 MB total ≈ 420 MB no gdalinfo, no exotic drivers your pipeline code rasterio built against system GDAL system GDAL, PROJ, and the CLI tools ubuntu-based GDAL image · ≈ 900 MB total ≈ 1.4 GB every driver, every tool, longer pulls

The wheel route is the right default. It is smaller, it builds without a compiler, and — most importantly — the GDAL and PROJ versions are determined by the wheel, so pinning rasterio==1.3.9 pins the whole stack. The system route earns its size when the pipeline shells out to gdalwarp, needs a driver the wheels omit, or has to match a specific GDAL version used elsewhere.

Step-by-step workflow

1. Start from a pinned, slim base

# syntax=docker/dockerfile:1
FROM python:3.11-slim-bookworm AS runtime

ENV PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

Pin the minor version of Python and the distribution release. python:3.11-slim alone floats to a new Debian base eventually, which is exactly the kind of silent change containers exist to prevent.

2. Install dependencies before code

COPY requirements.lock /tmp/requirements.lock
RUN pip install --require-hashes -r /tmp/requirements.lock
COPY src/ /app/src/

Dependencies change rarely and code changes constantly, so installing dependencies in an earlier layer means a code edit rebuilds one small layer instead of the whole image. --require-hashes turns the lock file into a guarantee rather than a suggestion.

3. Bake the GDAL configuration into the image

ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    GDAL_INGESTED_BYTES_AT_OPEN=65536 \
    GDAL_HTTP_MULTIRANGE=YES \
    GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
    GDAL_CACHEMAX=512 \
    VSI_CACHE=TRUE \
    VSI_CACHE_SIZE=50000000 \
    CPL_TMPDIR=/tmp \
    PROJ_NETWORK=OFF

Every one of these affects how many requests a remote read costs, and setting them in the image means every job inherits them without remembering. PROJ_NETWORK=OFF is deliberate: it prevents PROJ from silently downloading grids at runtime, which is both a reproducibility and a network-egress problem.

4. Add a build-time smoke test

RUN python - <<'PY'
import rasterio, pyproj
print("rasterio", rasterio.__version__, "GDAL", rasterio.__gdal_version__)
print("PROJ", pyproj.proj_version_str)
with rasterio.open("/vsicurl/https://example.com/tiny_cog.tif") as src:
    assert src.profile["tiled"], "sanity read failed"
PY

A smoke test that fails the build is worth far more than a README note. It catches a wheel that resolved differently, a missing certificate bundle, and a base image whose network configuration blocks remote reads — all of which otherwise surface as a failed job hours later.

5. Record what the image contains

LABEL org.opencontainers.image.title="raster-pipeline" \
      org.opencontainers.image.revision="${GIT_SHA}"
RUN python -c "import rasterio, pyproj, json, sys; \
    json.dump({'python': sys.version.split()[0], 'rasterio': rasterio.__version__, \
               'gdal': rasterio.__gdal_version__, 'proj': pyproj.proj_version_str}, \
              open('/app/versions.json','w'))"

Writing the resolved versions into the image means a job’s output can record which stack produced it, which is what makes a numeric difference between two runs diagnosable rather than mysterious.

Parameter reference

Setting Where Typical Why
GDAL_DISABLE_READDIR_ON_OPEN image env EMPTY_DIR Removes a sibling listing on every remote open
GDAL_CACHEMAX image env 256–1024 (MB) Block cache; too large starves the worker’s own memory
PROJ_NETWORK image env OFF Stops runtime grid downloads; ship grids instead
PROJ_DATA image env bundled path Points at the PROJ database the image actually contains
--require-hashes pip always Turns the lock file into an integrity guarantee
base image tag Dockerfile fully pinned A floating tag defeats the point of the container

Image size, and why it is a runtime cost

Where image size shows up in the bill For a task that runs for thirty seconds, pulling a 1.4 gigabyte image can take longer than the work itself, so most of the billed time is spent transferring the image. For a ten-minute task the pull is a rounding error. Array jobs over many short tasks are where a slim image pays for itself. Pull time versus work time, per container 30 s task, 1.4 GB image pull 42 s work 30 s 58% of billed time is image transfer 30 s task, 420 MB image pull 13 s work 30 s 30% — and the cached layers make repeats cheaper still 10 min task, either image work 600 s image size is irrelevant here — optimise the work instead Size matters in proportion to how often the image is pulled, which is a property of the job shape.

Three techniques account for most of the reduction. A multi-stage build compiles anything that needs compiling in a builder stage and copies only the resulting site-packages into the runtime stage, leaving the toolchain behind. Installing with --no-cache-dir avoids shipping pip’s cache. And ordering layers from least to most volatile means the expensive dependency layer is reused across code changes.

Layer order decides rebuild cost With dependencies installed before the application code is copied, a code change invalidates only the small final layer and the cached dependency layers are reused. With the order reversed, every code change reinstalls the entire geospatial stack, turning a five-second rebuild into several minutes. dependencies first code first FROM python:3.11-slim-bookworm ENV GDAL_* settings pip install -r requirements.lock (cached) COPY src/ ← changes often code edit rebuilds 1 small layer: ~4 s FROM python:3.11-slim-bookworm COPY . ← changes often pip install … (invalidated every time) ENV settings (also rebuilt) code edit reinstalls the whole stack: ~3 min The rule is simple: copy the lock file and install before copying anything that changes daily.

What belongs in the image, and what does not

An image is a contract about the environment, not a place to keep the pipeline’s inputs. Three things belong in it and three do not, and the boundary matters more as the fleet grows.

Belongs: the interpreter, the libraries, and the configuration that must be identical everywhere. That includes the GDAL environment variables above, the PROJ database, any datum grids the transformations depend on, and the pipeline code itself. All of these are the same for every job, they are small relative to imagery, and having them baked in means a job needs nothing but an item identifier to run.

Does not belong: credentials, imagery, and per-run configuration. Credentials should arrive from the platform’s identity mechanism at runtime, never from a layer — an image is copied, cached and shared, and a secret in one is a secret in all of them. Imagery is orders of magnitude larger than the code and changes constantly, so it belongs in object storage and is read with the windowed patterns from Reading a COG over S3 Without Downloading. Per-run configuration — dates, areas, thresholds — belongs in the job’s parameters, because baking it in means a new image for every run and destroys the cache.

The interesting boundary case is reference data: a small DEM, a coastline, a set of training polygons. If it is small, changes rarely, and every job needs it, baking it in removes a download per task and is worth the megabytes. If it is large or updated independently of the code, keep it in storage and read it like any other input. A useful test is whether an update to that file should require a new image build — if the answer is no, it does not belong in the image.

One more rule earns its keep at scale: one image per pipeline, not one per step. Separate images for “download”, “process” and “summarise” triple the number of things to pin, rebuild and pull, while the code they contain is nearly identical. A single image with several entry points keeps the environment identical across steps, which is exactly the property containers were adopted for.


Registries, caching and the cost of a rebuild

Where the image lives is as operationally significant as what is in it, and three properties of the registry decide how much time a fleet spends waiting.

Region proximity comes first. Pulling a 500 MB image from a registry on another continent costs both latency and inter-region transfer, per container. For an array job of ten thousand tasks that is a line item, not a rounding error, and the remedy is the same as for imagery: put the bytes near the compute, as argued in Reducing S3 Egress Costs in Raster Pipelines.

Layer reuse comes second. Compute fleets cache layers, so a fleet that has already run yesterday’s image pulls only the layers that changed. This is why the layer ordering above pays twice: it speeds up the build, and it means a code-only change ships a few megabytes to every node instead of the whole image. Rebuilding from a different base, or reordering the Dockerfile, invalidates the cache for everyone at once.

Tag discipline comes third and is the one most often skipped. A job definition that references :latest is not reproducible: two tasks in the same run can pull different images if a build lands between them, and re-running last month’s job silently uses this month’s code. Tag with the commit SHA, reference that tag in the job definition, and let :latest exist only as a convenience for humans.

A related question is when to rebuild at all. Three triggers are worth automating: a change to the lock file, a change to the pipeline code, and a periodic rebuild — monthly is common — to pick up base-image security updates. The third is the one that surprises teams, because it is the only one that can change behaviour without anyone editing a file. Running the smoke test on every rebuild is what turns that from a risk into a caught regression.

Finally, keep the build reproducible enough to be diagnostic. If an image built today behaves differently from one built in March, the fastest way to find out why is to compare the two versions.json files rather than to bisect the Dockerfile. That file costs one build step and has answered more “why did this change?” questions than any amount of logging — the same argument for recording provenance made in Writing and Validating Cloud-Optimized GeoTIFFs.

Running the same image locally and in the fleet

A container is only useful as a reproducibility tool if the environment developers use is the one that runs in production. Two habits keep them aligned.

Develop inside the image rather than beside it. Mounting the working tree into the container and running the pipeline there means the interpreter, GDAL and PROJ are the production ones, so an environment problem surfaces on a laptop instead of in a job log. It also removes the class of bug where code works locally because a system library happens to be installed.

Pass configuration the same way in both places. If the job scheduler supplies an item identifier through an environment variable, the local run should use the same variable rather than a command-line argument, so there is one code path. The array-index pattern in Processing COGs on AWS Batch with Docker is easy to reproduce locally by setting the variable by hand, and doing so catches entry-point bugs before they reach a fleet.


Verification and testing

Test the image, not the code. Three checks are worth automating:

# 1. The stack is what you think it is
docker run --rm raster-pipeline:latest cat /app/versions.json

# 2. A remote read works from inside the image, with the baked settings
docker run --rm raster-pipeline:latest python -c "
import rasterio
with rasterio.open('/vsicurl/https://example.com/tiny_cog.tif') as s:
    print(s.profile['tiled'], s.block_shapes[0], s.overviews(1))"

# 3. Two builds of the same commit produce the same versions
docker build -t a . && docker build -t b . && diff <(docker run --rm a cat /app/versions.json) \
                                                    <(docker run --rm b cat /app/versions.json)

The third is the one that catches unpinned dependencies, and it is the difference between an image that is reproducible and one that merely worked yesterday.

Troubleshooting

PROJ: proj_create_from_database: Cannot find proj.db

PROJ_DATA points at a path the runtime stage does not contain — usually because a multi-stage build copied site-packages but not the PROJ data directory. Copy both, or let the wheel’s bundled data be found by not setting the variable at all.

The image works locally and fails in the job scheduler

Architecture mismatch: an image built on an arm64 laptop will not run on amd64 compute. Build with --platform linux/amd64 explicitly, which is the CannotPullContainerError case in Processing COGs on AWS Batch with Docker.

Reprojections differ between the container and a workstation

Different PROJ versions, or the container lacks a datum grid the workstation has. Pin PROJ, ship the grids, and keep PROJ_NETWORK=OFF so behaviour does not depend on network reachability — the reproducibility argument in Transforming Point Coordinates with pyproj.

Every job spends a minute in STARTING

The image is large and not cached on the compute fleet. Slim the image, and prefer a registry in the same region as the compute so the pull is not crossing a network boundary.

pip install succeeds locally and fails in the build

A dependency needs a compiler that the slim base does not have. Either use a multi-stage build with a builder that does, or find a wheel — for the geospatial stack, a missing wheel usually means the version pin is older than the wheel coverage.

Frequently Asked Questions

Q: Wheels or system GDAL? Binary wheels for most pipelines: rasterio ships its own GDAL and PROJ, so the image is smaller, the build is faster and the versions are pinned by the wheel. Use a system GDAL image when you need the command-line tools, a driver the wheel omits, or a GDAL version the wheels do not offer.

Q: Why does the same code give different numbers in the container? Almost always a different PROJ version or a missing datum grid, which changes the transformation path chosen for a reprojection. Pin PROJ and ship the grids you depend on rather than relying on network access at runtime.

Q: Does image size actually matter? It matters most for array jobs, where every container pulls the image before doing any work. Shaving a 2 GB image to 600 MB removes tens of seconds per task, which across ten thousand tasks is hours of billed time.


Deep-Dive Articles