Building a Minimal QGIS Docker Image for Automation

Build a slim, reproducible QGIS Docker image for headless PyQGIS jobs: base image choice, installing python3-qgis, trimming GUI dependencies, and multi-stage…

TL;DR: Start FROM qgis/qgis:release-3_34 (or debian:bookworm-slim plus the QGIS apt repo), install only python3-qgis and your pip dependencies with --no-install-recommends, set QT_QPA_PLATFORM=offscreen, run as a non-root user, and order your layers so that changing application code never invalidates the heavy QGIS layer.

This page is part of the Containerizing QGIS with Docker guide, which sits inside the broader Headless Automation, CI/CD & Testing for PyQGIS reference. If you are packaging PyQGIS jobs to run in a pipeline or a scheduled worker, a lean, deterministic image is the foundation everything else builds on — the smaller and more cache-friendly it is, the faster your CI feedback loop.

Complete Runnable Dockerfile

The Dockerfile below produces a slim, headless QGIS image with nothing but the Python bindings, your pip dependencies, and your code. It is a drop-in template: change the requirements.txt, the copied source directory, and the entrypoint to match your project. Every design decision — layer order, --no-install-recommends, the non-root user, the offscreen Qt platform — is explained in the sections that follow.

dockerfile
# syntax=docker/dockerfile:1.7

# ---- Stage 1: dependency builder --------------------------------------
# A separate stage keeps build-only tooling (compilers, headers) out of
# the final image. Wheels built here are copied forward as artefacts.
FROM qgis/qgis:release-3_34 AS builder

ENV DEBIAN_FRONTEND=noninteractive \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Build wheels for pure-Python and compiled deps into a staging dir.
# Copy requirements FIRST so this layer is cached until deps change.
COPY requirements.txt /tmp/requirements.txt
RUN python3 -m pip wheel \
        --wheel-dir=/wheels \
        -r /tmp/requirements.txt

# ---- Stage 2: minimal runtime -----------------------------------------
FROM qgis/qgis:release-3_34 AS runtime

# Headless Qt: render offscreen, never touch an X server. Unbuffered
# Python so container logs stream immediately to the CI console.
ENV QT_QPA_PLATFORM=offscreen \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    DEBIAN_FRONTEND=noninteractive \
    PIP_NO_CACHE_DIR=1

# Install ONLY the QGIS Python bindings and skip recommended packages so
# no GUI/desktop tree is pulled in. The official image already carries
# python3-qgis, but this line makes the dependency explicit and portable
# if you swap the base for debian:bookworm-slim + the QGIS apt repo.
RUN apt-get update \
    && apt-get install --no-install-recommends -y \
        python3-qgis \
    && rm -rf /var/lib/apt/lists/*

# Install project deps from the pre-built wheels — no network, no compiler.
COPY --from=builder /wheels /wheels
COPY requirements.txt /tmp/requirements.txt
RUN python3 -m pip install --no-index --find-links=/wheels \
        -r /tmp/requirements.txt \
    && rm -rf /wheels /tmp/requirements.txt

# Create an unprivileged user. Never run automation as root.
RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 qgisrun
WORKDIR /app

# Copy application code LAST so edits here never bust the layers above.
COPY --chown=qgisrun:qgisrun src/ /app/src/

USER qgisrun

# Smoke-test at build time: fail the build if the bindings do not import.
RUN python3 -c "from qgis.core import Qgis; print('QGIS', Qgis.QGIS_VERSION)"

ENTRYPOINT ["python3", "/app/src/run_job.py"]

Build and run it:

bash
# Build, tagging with the QGIS version for traceability.
docker build -t my-org/qgis-automation:3.34 .

# Run a one-shot headless job, mounting a data directory read-write.
docker run --rm \
  -v "$(pwd)/data:/data" \
  my-org/qgis-automation:3.34 \
  --input /data/input.gpkg --output /data/output.gpkg

# Inspect the final image size; a minimal build should be well under
# the full desktop image.
docker images my-org/qgis-automation:3.34 --format '{{.Size}}'

Keep the build context small with a .dockerignore. Excluding version control, caches, and local data both speeds up the docker build context upload and prevents secrets or large files from leaking into layers:

ini
# .dockerignore
.git
.gitignore
__pycache__/
*.pyc
.pytest_cache/
.venv/
data/
*.gpkg
*.tif
*.log
.env
README.md

Layer-Cache Anatomy

Docker rebuilds every layer from the first changed instruction downward. The single biggest lever on rebuild speed is putting slow, stable layers at the top and volatile layers at the bottom. The diagram below shows which layers survive a code-only edit (the common case in CI) and which are rebuilt.

QGIS Image Layer Cache Anatomy A vertical stack of Docker layers from base image at the bottom to application code at the top. The base image, python3-qgis, and pip dependency layers are marked cached and reused. The application code layer at the top is marked rebuilt when source changes. Image layers (build order, bottom to top) COPY src/ (application code) volatile — changes every commit pip install -r requirements.txt stable — changes only with deps apt install python3-qgis heavy and stable FROM qgis/qgis:release-3_34 pinned base — rarely changes rebuilt on code change cache reused on code-only edits

Architecture Breakdown

Choosing a base image

There are two credible starting points, and the choice is a trade-off between convenience and control.

The official qgis/qgis images ship a complete QGIS install with the Python bindings already present, tracking upstream releases. Using a versioned tag like qgis/qgis:release-3_34 gives you a known-good QGIS quickly and is the pragmatic default for most automation. The cost is size: these images target desktop and testing use, so they carry more than a pure headless job strictly needs. Always pin to a release-* or dated tag rather than latestlatest is a moving target and will silently change QGIS versions between builds, which is exactly the non-reproducibility you are trying to avoid.

The debian:bookworm-slim + official QGIS apt repository route trades convenience for control. You start from a minimal Debian base and add only the packages you name, which yields the smallest possible image and lets you pin the exact QGIS point release from the QGIS repository. The cost is that you own the apt source configuration, the repository signing key, and the dependency list — more moving parts to maintain. Choose this when image size is a hard constraint, when you need a QGIS version the official images do not tag, or when your security posture requires auditing every installed package.

Whichever you pick, pin the tag and record the resolved QGIS version. The build-time smoke test in the Dockerfile (python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION)") both fails fast if the bindings are broken and prints the exact version into your build logs for traceability. This is the same version-awareness discipline you apply in code with Qgis.QGIS_VERSION_INT guards.

Layer ordering for cache hits

Docker caches each instruction as a layer keyed on the instruction plus the content it touches. When any layer’s inputs change, that layer and everything below it in the file are rebuilt. The practical rule: order instructions from least to most frequently changing.

In an automation image the base and the python3-qgis install almost never change, project pip dependencies change occasionally, and application code changes on every commit. So the file installs system and Python dependencies first, and copies src/ last. The subtle detail is that dependency installation is split from code: COPY requirements.txt and pip install happen before COPY src/. If you instead did COPY . . up front, editing a single Python file would invalidate the pip-install layer and reinstall every dependency on every build — turning a two-second rebuild into minutes.

For compiled dependencies, the multi-stage split earns its keep. The builder stage produces wheels; the runtime stage installs them with pip install --no-index --find-links=/wheels, so no compiler, header packages, or network access ever land in the final image. That keeps the runtime layer both smaller and more deterministic.

The --no-install-recommends flag is the single most effective size lever when installing QGIS packages through apt. Debian packages declare hard Depends and soft Recommends; by default apt installs both. For python3-qgis, the recommended set can pull in desktop integration, documentation, and other packages a headless job never touches. Passing --no-install-recommends installs only the hard requirements — the C++ libraries and Python bindings you actually import.

Pair the flag with cleanup in the same RUN instruction: rm -rf /var/lib/apt/lists/* after the install removes the apt package index from that layer. Because Docker layers are immutable, deleting those files in a later instruction would not reclaim the space — the bytes still live in the earlier layer. Combining update, install, and cleanup in one RUN keeps the whole operation inside a single layer whose net footprint excludes the index.

Because Qt still initialises even when you never open a window, set QT_QPA_PLATFORM=offscreen. This tells Qt to use the offscreen platform plugin, so QgsApplication starts without an X server or display. For most rendering-free processing — reprojection, geometry operations, attribute joins — offscreen alone is sufficient. Jobs that render map images or export layouts may still need a virtual framebuffer; that scenario is covered in Running Headless QGIS with Xvfb in Docker.

Registration and Integration: Using the Image as a CI Runner

The payoff of a minimal image is a fast, self-contained CI job. Because the image already carries QGIS and your dependencies, a pipeline only has to pull it and invoke your entrypoint — no per-run installation of the QGIS stack. In GitHub Actions you reference the image with the container: key so every step runs inside it:

yaml
name: qgis-automation
on: [push, pull_request]

jobs:
  run-headless-job:
    runs-on: ubuntu-latest
    container:
      image: my-org/qgis-automation:3.34
      # QT_QPA_PLATFORM is already baked into the image; override here
      # only if a step needs different behaviour.
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Verify QGIS bindings import
        run: python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION)"

      - name: Run the PyQGIS job
        run: python3 src/run_job.py --input data/input.gpkg --output out.gpkg

The same image doubles as a test runner: swap the final run step for your test command and you have a reproducible environment for Running QGIS Plugin Tests in GitHub Actions. Building the image on a tagged release and pushing it to a registry means every developer, CI job, and production worker executes against byte-identical QGIS and dependency layers — the reproducibility guarantee that motivated the pinned base in the first place.

Production Best Practices

  • Pin the base tag; never use latest. Use qgis/qgis:release-3_34 or a dated tag so builds are reproducible across machines and over time.
  • Split dependency installation from code copying. COPY requirements.txt and install before COPY src/, so editing a script never reinstalls dependencies.
  • Always pass --no-install-recommends on apt installs and clean /var/lib/apt/lists/* in the same RUN layer.
  • Set QT_QPA_PLATFORM=offscreen in the image so headless Qt initialises without a display; reserve Xvfb for jobs that actually render.
  • Run as a non-root user. Create a dedicated UID with useradd and switch with USER; mount data volumes with matching ownership.
  • Add a build-time import smoke test. python3 -c "from qgis.core import Qgis" fails the build early if the bindings are missing or mismatched.
  • Use a multi-stage build for compiled dependencies so compilers and headers never reach the runtime image.
  • Keep the build context lean with .dockerignore to speed up context upload and keep local data and secrets out of layers.
  • Tag images with the QGIS version (:3.34) so registries and pipelines make the version explicit and auditable.