Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python

Containerizing Geospatial Pipelines with Docker: Bundling GDAL, QGIS Scripts, and Python

1. Introduction: Containerizing Geospatial Pipelines Docker

Containerizing Geospatial Pipelines Docker

When Containerizing Geospatial Pipelines Docker architectures efficiently, developers often struggle with native dependency management. Geospatial Python applications depend on more than Python packages. A seemingly simple script that clips a raster, reprojects vector data, or processes satellite imagery may rely on several native libraries working together correctly.

GDAL provides access to a wide range of raster and vector formats. PROJ handles coordinate reference system transformations, while GEOS provides geometry operations used by libraries such as Shapely. Python packages such as Rasterio, Fiona, GeoPandas, and PyProj connect these capabilities to application code. QGIS adds its own processing framework and PyQGIS bindings, which must remain compatible with the QGIS installation and its underlying libraries.

These dependencies create a common deployment problem: a script that works on one computer may fail on another because the native libraries, Python packages, or environment variables differ.

For example, a developer might install one version of GDAL through the operating system, another through Conda, and a Python package that expects a different GDAL API. A coordinate transformation might also behave differently if PROJ’s database or grid resources are missing. Updating one package can break several others.

Docker addresses this problem by packaging the geospatial runtime into a versioned container image. The image can contain the operating-system dependencies, native spatial libraries, Python packages, application scripts, and configuration required to run a processing task. The same image can then be executed on a developer’s workstation, a build server, or a compatible cloud host.

A container does not automatically make every environment identical: host architecture, mounted files, permissions, external data sources, and runtime configuration still matter. Nevertheless, using pinned dependencies and a controlled container image makes the processing environment much more reproducible.

This guide builds a practical Docker workflow for geospatial processing, starting with a headless Python script and progressing to a reusable Dockerfile, mounted input/output directories, batch execution, and the additional configuration needed to run QGIS processing scripts inside a container.

2. Choose the Right Base Image

The base image determines which native libraries, Python runtime, command-line utilities, and spatial drivers are available inside the container.

Three approaches are common.

Base image strategy Best use Main consideration
ghcr.io/osgeo/gdal GDAL/OGR operations and Python bindings Choose a configuration with the required drivers
python:slim Lightweight Python applications using compatible precompiled spatial wheels Native libraries and Python bindings need deliberate compatibility management
qgis/qgis Scripts using PyQGIS and the QGIS Processing framework Larger image and QGIS-specific runtime configuration

The GDAL project publishes tagged container images through GitHub Container Registry, with configurations intended for different combinations of libraries and drivers. Its download documentation identifies the official container configurations and the current GDAL releases. 【turn817664view0】

For this guide, the main example uses a version-specific GDAL image:

FROM ghcr.io/osgeo/gdal:ubuntu-small-3.13.3

This tag illustrates the version-pinned approach; confirm that the exact tag and platform are available in the official registry before building. The small configuration may not contain every optional format driver, so use a more feature-complete configuration if your workflow depends on formats such as particular scientific raster or remote-sensing formats.

Important: Avoid installing an arbitrary PyPI GDAL version over the version supplied by the base image. GDAL’s Python bindings depend on the underlying native GDAL library, and NumPy compatibility can also affect compiled extensions. 【turn814146view3】

If you require GeoPandas, Rasterio, Shapely, and GDAL Python bindings together, choose a compatible package set and test it as one environment. A Conda-forge environment or a deliberately maintained compatible image is preferable to indiscriminately mixing native packages from different sources.

3. Step 1 — Write a Headless Python Spatial Processing Script

We will build a batch raster reprojection pipeline.

The script will:

  1. Read GeoTIFF files from an input directory.

  2. Validate that each raster can be opened and has a defined coordinate reference system.

  3. Reproject each raster using the GDAL Python API.

  4. Write tiled, compressed GeoTIFF outputs into a separate directory.

  5. Log individual failures and return a nonzero exit code if processing fails for any file.

Unlike a desktop GIS workflow, the script does not need to open a graphical interface. This makes it appropriate for Docker, scheduled jobs, and continuous integration.

Organize the project

Create the following directory structure:

geo-pipeline/
├── Dockerfile
├── requirements.txt
├── .dockerignore
├── src/
│   └── geoworkflow/
│       ├── __init__.py
│       └── reproject.py
└── data/
    ├── input/
    └── output/

The src/geoworkflow/ directory contains application code, while the data/ directory holds local processing data. The data directory will be mounted at runtime rather than copied into the image.

Create src/geoworkflow/reproject.py

"""Batch raster reprojection with GDAL.Input:  GeoTIFF files in an input directory.Output: Reprojected GeoTIFF files in an output directory."""import argparseimport loggingimport osimport tempfilefrom pathlib import Pathfrom osgeo import gdal# Make GDAL raise Python exceptions for processing failures.gdal.UseExceptions()logging.basicConfig(    level=logging.INFO,    format="%(asctime)s | %(levelname)s | %(message)s",)logger = logging.getLogger("geoworkflow")def reproject_raster(    source_path: Path,    output_path: Path,    target_crs: str,    overwrite: bool = False,) -> None:    """Reproject one raster and write a GeoTIFF result."""    if output_path.exists() and not overwrite:        raise FileExistsError(            f"Output already exists: {output_path}"        )    # Open the source raster in read-only mode.    source = gdal.Open(str(source_path), gdal.GA_ReadOnly)    if source is None:        raise RuntimeError(            f"GDAL could not open {source_path}"        )    try:        # Refuse to guess the source coordinate reference system.        if not source.GetProjection():            raise ValueError(                f"Source raster has no CRS: {source_path}"            )        # Create a temporary output in the destination directory.        # Keeping it in the same directory permits an atomic rename        # on common local filesystems after a successful write.        with tempfile.NamedTemporaryFile(            dir=output_path.parent,            suffix=".tif",            prefix=f".{output_path.stem}_",            delete=False,        ) as temp_file:            temporary_path = Path(temp_file.name)        try:            # Warp to the requested CRS, with GeoTIFF compression.            options = gdal.WarpOptions(                format="GTiff",                dstSRS=target_crs,                creationOptions=[                    "TILED=YES",                    "COMPRESS=DEFLATE",                ],                multithread=True,            )            result = gdal.Warp(                str(temporary_path),                source,                options=options,            )            if result is None:                raise RuntimeError(                    f"GDAL reprojection failed: {source_path}"                )            # Flush output and close the file before replacing.            result.FlushCache()            result = None            if not Path(temporary_path).exists():                raise RuntimeError(                    "GDAL did not create the output file."                )            if not overwrite and output_path.exists():                raise FileExistsError(output_path)            os.replace(temporary_path, output_path)            logger.info(                "Reprojected %s -> %s",                source_path.name,                output_path.name,            )        finally:            # Remove incomplete temporary output after a failure.            if temporary_path.exists():                temporary_path.unlink()    finally:        # Release the GDAL dataset handle.        source = Nonedef main() -> int:    """Parse arguments and process every matching raster."""    parser = argparse.ArgumentParser(        description="Batch-reproject GeoTIFF files using GDAL."    )    parser.add_argument(        "--input-dir",        type=Path,        default=Path("/data/input"),        help="Directory containing source GeoTIFF files.",    )    parser.add_argument(        "--output-dir",        type=Path,        default=Path("/data/output"),        help="Directory for reprojected rasters.",    )    parser.add_argument(        "--dst-crs",        required=True,        help="Target CRS, for example EPSG:4326.",    )    parser.add_argument(        "--overwrite",        action="store_true",        help="Replace existing output files.",    )    args = parser.parse_args()    if not args.input_dir.is_dir():        logger.error(            "Input directory does not exist: %s",            args.input_dir,        )        return 2    args.output_dir.mkdir(parents=True, exist_ok=True)    rasters = sorted(        path        for path in args.input_dir.iterdir()        if path.is_file()        and path.suffix.lower() in {".tif", ".tiff"}    )    if not rasters:        logger.error(            "No GeoTIFF files found in %s",            args.input_dir,        )        return 2    failures = []    for source_path in rasters:        output_path = (            args.output_dir / f"{source_path.stem}_reprojected.tif"        )        try:            reproject_raster(                source_path=source_path,                output_path=output_path,                target_crs=args.dst_crs,                overwrite=args.overwrite,            )        except Exception as exc:            logger.exception(                "Failed to process %s: %s",                source_path.name,                exc,            )            failures.append(source_path.name)    logger.info(        "Finished batch: %d total, %d failed.",        len(rasters),        len(failures),    )    if failures:        logger.error("Failed files: %s", ", ".join(failures))        return 1    return 0if __name__ == "__main__":    raise SystemExit(main())

Create an empty src/geoworkflow/__init__.py file so Python can import the module.

The script deliberately requires a target CRS and refuses to process rasters whose source CRS is missing. This prevents a common spatial data error: transforming coordinates from an unknown source reference system.

It also uses temporary outputs so that a failed transformation does not normally leave a partial file under the final output name. For workflows involving interrupted jobs, network filesystems, or very large files, implement additional cleanup and recovery checks suited to the storage environment.

CRS note: EPSG:4326 is used in the examples below. Choose an appropriate target CRS for your application, and verify that the source data has correctly defined spatial metadata. Reprojection changes the coordinate representation; it does not automatically improve the accuracy of the input data.

4. Step 2 — Build the Dockerfile

Now package the GDAL runtime and custom Python code into an image.

Create a file named requirements.txt:

# Add application-level dependencies here and pin tested versions.
#
# The base image supplies GDAL and its Python bindings.
# Do not install an unrelated GDAL version from PyPI.

The file is intentionally a template because the demonstrated script depends only on Python’s standard library and the GDAL Python bindings from the base image. When you introduce additional libraries, add them here only after confirming compatibility with the spatial runtime.

Create Dockerfile

# Use a version-specific GDAL image.
# Verify the selected tag in the official registry.
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.13.3

# Make Python logs appear immediately in container output.
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Install venv support while retaining the image's system
# Python packages and matching GDAL bindings.
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        python3-venv \
    && rm -rf /var/lib/apt/lists/*

# Use the same system interpreter as the GDAL bindings.
RUN python3 -m venv --system-site-packages /opt/venv

ENV PATH="/opt/venv/bin:${PATH}"

WORKDIR /opt/app

# Copy dependency definitions separately to preserve build caching.
COPY requirements.txt /opt/app/requirements.txt

# Install application dependencies without retaining pip caches.
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application and supporting Python modules.
COPY src/ /opt/app/src/

ENV PYTHONPATH="/opt/app/src"

# The container runs the pipeline by default.
ENTRYPOINT ["python", "-m", "geoworkflow.reproject"]

# Default arguments can be overridden when the container runs.
CMD [
    "--input-dir", "/data/input",
    "--output-dir", "/data/output",
    "--dst-crs", "EPSG:4326"
]

Why the Dockerfile is structured this way

The spatial runtime comes first. GDAL and its compatible Python bindings are inherited from the base image rather than rebuilt separately using pip.

The Python environment is deliberate. The virtual environment uses --system-site-packages, allowing the Python process to access the system’s GDAL bindings. Third-party packages installed later should not replace or upgrade those spatial dependencies without a compatibility test.

Dependencies are copied before source code. Docker can reuse the dependency-installation layer when the Python application changes but requirements.txt remains unchanged. Docker recommends separating build stages where appropriate, using trusted and appropriately sized base images, and keeping image layers efficient. 【turn814146view2】

The entrypoint makes the image reusable. The image runs the same processing module each time, while arguments such as input directory, output directory, and target CRS can change without rebuilding the image.

Add .dockerignore

Keep unnecessary files out of the build context.

.git/
.venv/
__pycache__/
*.py[cod]

data/
output/
*.tif
*.tiff
*.gpkg
*.shp
*.shx
*.dbf
*.prj

Excluding source datasets is particularly important. You generally want the image to contain the processing application, not a copy of every raster or shapefile that it will process.

Build the image

From the project root, run:

docker build --pull -t geo-pipeline:gdal-3.13.3 .

The --pull option checks for an updated copy of the base image. For reproducible production releases, use a maintained, version-specific base reference and consider pinning its digest as well. Updating a base image should be a deliberate rebuild-and-test event, rather than an uncontrolled change during a routine run. 【turn814146view2】

Verify the runtime

Check that GDAL is available:

docker run --rm \
  --entrypoint gdalinfo \
  geo-pipeline:gdal-3.13.3 \
  --version

Check the Python bindings:

docker run --rm \
  --entrypoint python \
  geo-pipeline:gdal-3.13.3 \
  -c "from osgeo import gdal; print(gdal.VersionInfo('RELEASE_NAME'))"

Both checks should complete successfully. A failure at this stage should be fixed before introducing real input datasets.

5. Step 3 — Mount Local Directories into the Container

Diagram showing local directory volume mounting into a Docker container for geospatial pipelines

A container image contains the software; mounted volumes provide the data.

This separation is essential in geospatial workflows because raster datasets, satellite imagery, and large vector collections may be hundreds of megabytes or several gigabytes. Copying them into an image would increase its size and require rebuilding whenever the data changed.

Place a few test GeoTIFF files in data/input/. Create data/output/ if it does not already exist.

Run the container:

docker run --rm \
  --mount type=bind,source="$(pwd)/data",target=/data \
  geo-pipeline:gdal-3.13.3 \
  --input-dir /data/input \
  --output-dir /data/output \
  --dst-crs EPSG:4326

The command maps the host’s data/ directory to /data/ inside the container. The script reads from /data/input and writes to /data/output.

How the mounted volume works

Host machine

geo-pipeline/data/input/*.tif

geo-pipeline/data/output/

Bind mount: host data ↔ /data

Docker container

Reads /data/input

Writes /data/output

Runs GDAL through Python

The --rm flag removes the stopped container, but it does not remove the files written to the mounted host directory. You can replace the rasters in data/input/ and run the existing image again without rebuilding it.

Override the target CRS

The image declares default arguments, but the command line can override them:

docker run --rm \
  --mount type=bind,source="$(pwd)/data",target=/data \
  geo-pipeline:gdal-3.13.3 \
  --input-dir /data/input \
  --output-dir /data/output \
  --dst-crs EPSG:32737 \
  --overwrite

The destination CRS is now UTM zone 37S, which may be suitable for some datasets in that region. Always choose a CRS appropriate to the actual study area and analytical purpose.

On Windows PowerShell, a typical volume command uses ${PWD}:

docker run --rm `
  --mount "type=bind,source=$($PWD.Path)\data,target=/data" `
  geo-pipeline:gdal-3.13.3 `
  --input-dir /data/input `
  --output-dir /data/output `
  --dst-crs EPSG:4326

Make sure Docker Desktop has permission to access the chosen host directory. On Linux, file ownership can also matter: a container process running as root may create output files that your regular user cannot easily modify. Use an appropriate non-root runtime user or pass a suitable user ID where the image and deployment environment support it.

6. Step 4 — Run PyQGIS in a Docker Container

Running headless PyQGIS processing workflows inside a Docker containe

Not every workflow should use the generic GDAL base image.

If your code invokes QGIS Processing algorithms, loads QGIS plugins, or relies on QGIS-specific classes such as QgsVectorLayer, use a QGIS image. QGIS has its own Python bindings and application dependencies, so layering PyQGIS into an arbitrary Python image can create another dependency conflict.

The official QGIS Docker project publishes images for QGIS Desktop and QGIS Server. At the time of writing, the generic stable and latest tags still point to QGIS 3.x on Ubuntu Noble, while distribution-specific tags are available for QGIS 4.0. Pin the QGIS release and base distribution appropriate to your application instead of treating stable as an immutable version. 【turn370650view0】

Minimal QGIS Dockerfile

Create a separate Dockerfile named Dockerfile.qgis:

# For a quick test, use the official QGIS image.
# For production, pin a validated release/distro tag.
FROM qgis/qgis:stable

ENV QT_QPA_PLATFORM=offscreen
ENV PYTHONUNBUFFERED=1

WORKDIR /opt/qgis-app

COPY scripts/ /opt/qgis-app/scripts/

# QGIS prefix and Python paths are image-specific.
ENV QGIS_PREFIX_PATH=/usr
ENV PYTHONPATH=/usr/share/qgis/python:/usr/share/qgis/python/plugins

ENTRYPOINT ["python3", "/opt/qgis-app/scripts/process_layer.py"]

The QT_QPA_PLATFORM=offscreen setting allows Qt-based code to operate without opening a desktop window. Confirm the QGIS prefix and Python paths against the selected image.

Example headless PyQGIS script

Create scripts/process_layer.py:

"""Reproject a vector layer using the QGIS Processing framework."""import osimport sysimport loggingfrom pathlib import Path# Set the headless Qt platform before importing QGIS.os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")from qgis.core import (    QgsApplication,    QgsCoordinateReferenceSystem,)logging.basicConfig(level=logging.INFO)logger = logging.getLogger("pyqgis-pipeline")def main() -> int:    input_layer = "/data/input.gpkg|layername=parcels"    output_file = "/data/output/reprojected.gpkg"    if not Path("/data/input.gpkg").exists():        logger.error("Input GeoPackage does not exist.")        return 2    Path(output_file).parent.mkdir(        parents=True,        exist_ok=True,    )    # Initialize QGIS without a graphical interface.    QgsApplication.setPrefixPath(        os.environ.get("QGIS_PREFIX_PATH", "/usr"),        True,    )    app = QgsApplication([], False)    app.initQgis()    try:        # Initialize the QGIS Processing framework.        from processing.core.Processing import Processing        Processing.initialize()        import processing        result = processing.run(            "native:reprojectlayer",            {                "INPUT": input_layer,                "TARGET_CRS": QgsCoordinateReferenceSystem(                    "EPSG:4326"                ),                "OUTPUT": output_file,            },        )        logger.info("Processing completed: %s", result["OUTPUT"])        return 0    except Exception:        logger.exception("QGIS Processing failed.")        return 1    finally:        app.exitQgis()if __name__ == "__main__":    raise SystemExit(main())

This example assumes the input GeoPackage contains a layer named parcels, and that the output path is writable. Ensure the QGIS Processing plugin and native algorithms are available in the chosen image.

Build the separate QGIS image:

docker build \
  -f Dockerfile.qgis \
  -t geo-pipeline:qgis \
  .

Run it with a host directory mounted:

docker run --rm \
  --mount type=bind,source="$(pwd)/data",target=/data \
  geo-pipeline:qgis

In practice, maintain separate GDAL and QGIS images when they serve different jobs. A basic raster reprojection script does not need the extra weight and complexity of a full QGIS installation.

7. Step 5 — Orchestrate Containers for Automated Batch Jobs

Once the image works locally, the next step is to make the pipeline repeatable.

Docker Compose provides a convenient way to store runtime configuration alongside the application. Create compose.yaml:

services:
  raster-reproject:
    build:
      context: .
      dockerfile: Dockerfile

    image: geo-pipeline:gdal-3.13.3

    volumes:
      - ./data:/data

    command:
      - --input-dir
      - /data/input
      - --output-dir
      - /data/output
      - --dst-crs
      - ${DST_CRS:-EPSG:4326}

    restart: "no"

Run the batch process with the default target CRS:

docker compose run --rm raster-reproject

Override it without editing the file:

DST_CRS=EPSG:32737 \
docker compose run --rm raster-reproject

The same approach can be called by a scheduled job, a CI/CD runner, or a larger workflow orchestrator.

For processing separate batches independently, a shell loop can launch one container per batch:

#!/usr/bin/env bash
set -Eeuo pipefail

IMAGE="geo-pipeline:gdal-3.13.3"
DATA_ROOT="$(pwd)/data"

for batch_dir in "$DATA_ROOT"/batches/*; do
    # Skip unmatched glob patterns and non-directory entries.
    [[ -d "$batch_dir" ]] || continue

    batch_name="$(basename "$batch_dir")"
    output_dir="$DATA_ROOT/results/$batch_name"

    mkdir -p "$output_dir"

    echo "Processing batch: $batch_name"

    docker run --rm \
        --mount \
        "type=bind,source=$batch_dir,target=/data/input,readonly" \
        --mount \
        "type=bind,source=$output_dir,target=/data/output" \
        "$IMAGE" \
        --input-dir /data/input \
        --output-dir /data/output \
        --dst-crs EPSG:4326
done

This pattern isolates each batch’s input and output, which makes reruns and error investigation easier. The script will stop on the first unsuccessful container because set -e is enabled; modify that behavior if you want to process every batch and produce a consolidated failure report instead.

For a large production workload, also consider the number of simultaneous jobs, available RAM, raster dimensions, disk capacity, and the effect of multithreaded GDAL processing. More containers do not automatically mean faster processing.

8. Lightweight Geospatial Containers: Best Practices

Keep the runtime minimal

Install only the libraries your code needs. If the task is a simple GDAL raster transformation, start with a GDAL-focused image rather than installing the entire QGIS desktop stack. A smaller image generally reduces download time and the number of packages that require maintenance. Docker’s official build guidance discusses choosing appropriate base images, using multi-stage builds where beneficial, and minimizing unnecessary packages. 【turn814146view2】

Pin and test the complete spatial stack

Record the base image version, Python package versions, and the tested source and target CRS behavior. For high-reproducibility workloads, record the container digest and preserve the lockfile or complete environment specification. Test upgrades before promoting the image to production.

Be particularly cautious with the GDAL Python bindings and NumPy ABI compatibility. These are compiled dependencies, not just independent Python modules. 【turn236414search5】

Keep data and code separate

Use mounted volumes for large inputs and outputs. Keep credentials outside the image, and avoid committing local spatial data to the Docker build context. This separation makes the image reusable for different datasets and environments.

Validate inputs and outputs

Do not measure success solely by the process exit code. For critical workflows, validate that the expected files exist, output datasets can be opened, CRS metadata is correct, feature or raster dimensions are reasonable, and processing statistics match expected ranges.

Treat logging as an operational requirement

Write structured or timestamped logs to standard output and standard error. This allows Docker and your orchestration platform to collect execution records without writing logs into the image filesystem.

Automate builds and tests

A geospatial DevOps workflow can build the image whenever application code changes, run tests against a small sample dataset, verify GDAL and QGIS availability, and publish a tagged image only when the tests pass. Docker recommends automating image builds and tests in CI/CD pipelines. 【turn370650view2】

9. Summary

Containerizing geospatial pipelines makes complex spatial runtimes easier to deploy and maintain. Docker packages the application code alongside a controlled runtime, reducing the environment drift that often occurs when GDAL, PROJ, GEOS, Python, and QGIS dependencies are installed separately on multiple machines.

The workflow developed here demonstrates how to write a headless GDAL reprojection script, build a version-specific image, mount local data directories, process batches without rebuilding the image, and orchestrate recurring runs through Docker Compose or shell automation.

Use a GDAL-focused image for straightforward raster and vector operations, and a compatible QGIS image when your processing depends on PyQGIS or native QGIS algorithms. Keep native spatial dependencies compatible, pin versions, validate outputs, separate data from code, and automate tests before deployment.

The goal of a geospatial DevOps workflow is not merely to make a script run inside Docker. It is to make the processing environment repeatable, testable, maintainable, and reliable across machines and deployments.