Containerizing QGIS with Docker
Package QGIS and PyQGIS into reproducible Docker images for CI and automation — base image choice, PROJ/GDAL data, Xvfb for GUI calls, and slim multi-stage…
A PyQGIS pipeline that runs cleanly on a developer laptop can fail the moment it lands on a CI runner or a production server, because QGIS behaviour is a function of three moving parts — the QGIS build itself, the GDAL version underneath it, and the PROJ datum grids on disk. A datum transformation that resolves locally can silently fall back to a lower-accuracy path elsewhere; a processing algorithm present in 3.34 may be absent in 3.28. “Works on my machine” is, in geospatial work, almost always a versioning story. Packaging QGIS into a Docker image pins all three parts together into a single reproducible artifact you can run identically across dev, CI, and prod. This page, part of the Headless Automation, CI/CD & Testing for PyQGIS guide, covers base-image choice, where PyQGIS and the PROJ/GDAL data live inside the image, why some calls still need an X server, and how to build slim images that stay fast to rebuild.
Prerequisites Checklist
Before writing a Dockerfile, confirm the following are in place:
- Docker Engine 24+ (or a compatible runtime): BuildKit is enabled by default and gives you build-cache mounts and multi-stage support used later on this page.
- A target QGIS version, pinned: Decide on QGIS 3.28 LTR (or another long-term release) and use it everywhere. Never rely on a floating
latesttag — it will change under you and break reproducibility. - Base-image decision: Either the official
qgis/qgisimage (fastest path, batteries included) or adebian:bookwormbase with the official QGIS apt repository added by hand. This page shows the Debian route because it exposes every layer; the trade-offs are discussed under Architecture below. - A pinned
requirements.txt: Any additional Python packages (pytest,requests, database drivers) should be version-locked so the image is byte-reproducible. - Familiarity with headless execution: The container runs the same code you would run outside QGIS Desktop; if standalone execution is new, read Standalone PyQGIS Scripts and Headless Execution first, as it covers
QgsApplicationinitialisation that the container merely wraps.
A container is a packaging and isolation layer, not a substitute for correct headless code. It guarantees the same binaries and grids everywhere — it does not fix a script that assumes a running desktop.
Architecture: How a QGIS Image Is Structured
A QGIS container is a stack of filesystem layers, and understanding which layers change often versus rarely is what keeps rebuilds fast. The diagram below shows the canonical layering from the base OS up to the entrypoint.
Where PyQGIS lives. In the official qgis/qgis image and in a Debian install of the python3-qgis package, the Python bindings are installed into the system dist-packages directory (for example /usr/lib/python3/dist-packages/qgis/). They are SIP-generated wrappers over the QGIS C++ libraries, so import qgis.core only works with the exact Python interpreter the package was built against. This is why you install PyQGIS through apt rather than pip — there is no meaningful pip install qgis, and mixing a pip Python with the system QGIS produces ImportError on the compiled extension modules.
PROJ_LIB and GDAL_DATA. Coordinate transformations depend on data files, not just code: the EPSG database, datum shift grids, and GDAL’s support tables. PROJ looks in the directory named by PROJ_LIB (or PROJ_DATA on newer PROJ), and GDAL looks in GDAL_DATA. The apt packages set these up, but if you copy binaries between images or strip files in a multi-stage build you must set these variables explicitly, or every reprojection silently degrades. This is the containerised face of the same CRS pitfalls covered in coordinate transformations and CRS handling.
Why an X server is sometimes still required. QGIS is a Qt application, and constructing a QApplication (which QgsApplication subclasses) needs a platform plugin. For headless work you set QT_QPA_PLATFORM=offscreen, which is a real plugin that satisfies Qt without any display — pure vector/raster processing runs happily under it. But some code paths reach into actual GUI rendering: map canvas image snapshots, certain print-layout export routines, and any plugin that instantiates widgets. Those call Qt drawing code the offscreen plugin does not fully back, and they fail or crash without a display. The fix is Xvfb, a virtual framebuffer X server: xvfb-run starts a throwaway :99 display, runs your command against it, and tears it down. The deep-dive on exactly when and how to wire this is Running Headless QGIS with Xvfb in Docker.
Step-by-Step: A Working Headless PyQGIS Image
The following Dockerfile builds a headless PyQGIS image on a Debian base. It is ordered so that the expensive, stable layers (QGIS install) sit below the volatile ones (your code), maximising build-cache reuse.
# syntax=docker/dockerfile:1
FROM debian:bookworm-slim
# --- OS + QGIS apt repository (cache-stable layer) ---
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
gnupg ca-certificates wget xvfb \
&& mkdir -p /etc/apt/keyrings \
&& wget -qO /etc/apt/keyrings/qgis.asc https://download.qgis.org/downloads/qgis-archive-keyring.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/qgis.asc] https://qgis.org/debian bookworm main" \
> /etc/apt/sources.list.d/qgis.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
qgis python3-qgis python3-pip \
&& rm -rf /var/lib/apt/lists/*
# --- PROJ / GDAL data + headless Qt platform (stable) ---
# The apt packages set these, but declaring them makes the contract explicit
# and survives multi-stage copies.
ENV QT_QPA_PLATFORM=offscreen \
PROJ_LIB=/usr/share/proj \
GDAL_DATA=/usr/share/gdal \
PYTHONUNBUFFERED=1
# --- Python dependencies (rebuilds only when requirements change) ---
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --break-system-packages -r requirements.txt
# --- Application code (most volatile layer, copied last) ---
COPY src/ ./src/
# --- Non-root runtime user ---
RUN useradd --create-home --uid 1000 runner
USER runner
# xvfb-run gives GUI-touching calls a virtual display; harmless for pure data work
ENTRYPOINT ["xvfb-run", "-a", "--server-args=-screen 0 1024x768x24", \
"python3", "src/job.py"]
A minimal src/job.py that the entrypoint drives — note it initialises QgsApplication in headless mode exactly as a standalone script would:
"""Headless PyQGIS smoke job: initialise QGIS, log versions, run a reprojection."""
from __future__ import annotations
import sys
from qgis.core import (
Qgis,
QgsApplication,
QgsCoordinateReferenceSystem,
QgsCoordinateTransform,
QgsPointXY,
QgsProject,
)
def run() -> int:
"""Boot a headless QGIS app, report versions, and prove PROJ works.
Returns:
Process exit code: 0 on success, non-zero on failure.
"""
# No GUI: pass an empty prefix and False for the GUI-enabled flag.
qgs = QgsApplication([], False)
qgs.initQgis()
try:
print(f"QGIS {Qgis.QGIS_VERSION} (int {Qgis.QGIS_VERSION_INT})")
src = QgsCoordinateReferenceSystem("EPSG:4326")
dst = QgsCoordinateReferenceSystem("EPSG:27700")
if not (src.isValid() and dst.isValid()):
print("CRS failed to resolve — check PROJ_LIB", file=sys.stderr)
return 1
transform = QgsCoordinateTransform(src, dst, QgsProject.instance())
pt = transform.transform(QgsPointXY(-0.1276, 51.5072)) # London
print(f"Reprojected point: {pt.x():.1f}, {pt.y():.1f}")
return 0
finally:
qgs.exitQgis()
if __name__ == "__main__":
raise SystemExit(run())
Build and run the image, mounting a host directory so results land outside the container:
# Build (BuildKit caches the QGIS apt layer across rebuilds)
docker build -t pyqgis-job:3.28 .
# Run the default entrypoint; mount ./data read-write for outputs
docker run --rm \
-v "$(pwd)/data:/app/data" \
pyqgis-job:3.28
# Override the entrypoint for pure-data work that never touches the GUI:
# skip xvfb-run entirely and rely on the offscreen Qt platform plugin.
docker run --rm \
-e QT_QPA_PLATFORM=offscreen \
-v "$(pwd)/data:/app/data" \
--entrypoint python3 \
pyqgis-job:3.28 src/job.py
If the smoke job prints a QGIS version banner and a reprojected easting/northing near 530000, 180000, the image is correctly wired: PyQGIS imports, the offscreen platform boots, and PROJ found its grids.
Advanced Patterns
Minimal and Multi-Stage Slim Images
The image above is convenient but large — a full qgis install pulls in the desktop application, its icons, and dependencies you never invoke from a script. For CI images that are pulled thousands of times, size directly costs minutes. A multi-stage build lets you install into a build stage, then copy only the runtime artifacts (the QGIS libraries, PyQGIS bindings, and PROJ/GDAL data) into a clean final stage, dropping GUI-only packages. The trade-off is fragility: if you strip a shared library that a lazily-loaded processing provider needs, the failure surfaces only when that code path runs. The companion page Building a Minimal QGIS Docker Image for Automation walks through which packages are safe to remove, how to verify the bindings still import, and how to shrink a typical image by more than half without breaking coordinate transformations.
GUI-Dependent Calls Under Xvfb
Most automation never needs a display, but the exceptions are exactly the operations people most want to automate: rendering a map canvas to PNG, exporting a print layout, or generating an atlas series. These invoke Qt paint code that the offscreen plugin does not fully implement, so they need a real X server. xvfb-run -a allocates a free display number, runs the command, and cleans up — the pattern baked into the entrypoint above. When you need more control (a fixed screen depth for consistent anti-aliasing, or a long-lived Xvfb shared across many invocations in one CI job), you start Xvfb explicitly and export DISPLAY. The full matrix of which QGIS calls need Xvfb versus offscreen, plus the exact server arguments that avoid rendering artifacts, is covered in Running Headless QGIS with Xvfb in Docker.
Mounting Data Volumes and Caching the Processing Registry
Data should not live inside the image. Bake code and dependencies into layers; mount datasets and outputs as volumes at run time (-v host:container) so the same image processes different inputs without a rebuild. There is one subtlety specific to QGIS: the Processing framework builds a registry of algorithm providers at startup, and if your script calls processing.run(...) you must initialise that framework explicitly in a headless process.
"""Initialise the Processing framework in a headless container run."""
from __future__ import annotations
from qgis.analysis import QgsNativeAlgorithms
from qgis.core import QgsApplication
import processing
from processing.core.Processing import Processing
def bootstrap_processing() -> QgsApplication:
"""Boot QGIS and register native processing algorithms for headless use.
Returns:
The live QgsApplication; the caller is responsible for exitQgis().
"""
qgs = QgsApplication([], False)
qgs.initQgis()
# Register the C++ 'native:' algorithm provider — required off the GUI.
Processing.initialize()
QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())
return qgs
Registering providers costs a fixed startup penalty on every container invocation. For batch jobs that process many files, initialise Processing once per container and loop over inputs inside a single process, rather than launching a fresh container per file — the registry cannot be persisted into an image layer because it is rebuilt in memory at each boot.
Pitfalls and Debugging
-
Image bloat from the full desktop install:
apt-get install qgisdrags in the desktop GUI, help files, and sample data. For automation, preferpython3-qgisplus only the providers you use, and cleanaptlists in the sameRUNlayer (rm -rf /var/lib/apt/lists/*) so the deletion actually reduces layer size rather than being masked by an earlier layer. -
Missing GUI libraries at runtime: A slimmed image that imports fine can still crash with
could not load the Qt platform plugin "xcb"or a missinglibGL.so.1the moment a rendering call runs. The offscreen plugin needs fewer libraries than xcb, but layout export still needs fontconfig and oftenlibgl1. Installlibgl1and a font package (fonts-dejavu-core) if any layout or canvas rendering is in scope. -
PROJ downloading grids over the network: PROJ 7+ can fetch datum grids on demand from a CDN. In a sandboxed CI runner with no egress, high-accuracy transformations then fail or silently fall back. Either bake the required grids into the image and point
PROJ_LIB/PROJ_DATAat them, or explicitly disable network access withPROJ_NETWORK=OFFso the failure is loud and reproducible rather than environment-dependent. -
Mismatched QGIS version versus local: A
.qgsproject or processing parameter authored against 3.28 can behave differently under 3.34. Pin the base image to the same LTR tag your team develops against, and assert it at runtime —Qgis.QGIS_VERSION_INTlets a script fail fast with a clear message instead of producing subtly wrong output. This same discipline underpins reliable continuous integration for QGIS projects. -
Running as root: The default container user is root, which means files written to a mounted volume are owned by root on the host — a common source of “permission denied” on the next local run. Create a non-root
runneruser (as in the Dockerfile above) and match its UID to the host user where possible, or pass--user "$(id -u):$(id -g)"at run time. -
QStandardPaths: XDG_RUNTIME_DIR not setwarnings: Harmless in most headless runs, but noisy in logs. SetXDG_RUNTIME_DIR=/tmp/runtime-runnerand create the directory with mode0700if the warning obscures real errors in CI output.
Conclusion
A QGIS Docker image is the reproducibility contract for geospatial automation: it pins QGIS, GDAL, and PROJ — and the datum grids they depend on — into one artifact that runs identically on a laptop, a CI runner, and a production host. Choose the official qgis/qgis image to move fast or a Debian base for a slim, controlled build; keep the expensive QGIS layers below your volatile application code so rebuilds stay quick; set PROJ_LIB and GDAL_DATA explicitly and disable PROJ network access for determinism; and reach for xvfb-run only on the GUI-touching calls that genuinely need a display, relying on QT_QPA_PLATFORM=offscreen everywhere else. With those pieces in place, the container becomes a dependable foundation for headless jobs, test suites, and scheduled pipelines.
Related
- Headless Automation, CI/CD & Testing for PyQGIS — parent guide covering the full automation and testing stack
- Building a Minimal QGIS Docker Image for Automation — multi-stage builds that halve image size without breaking PyQGIS
- Running Headless QGIS with Xvfb in Docker — giving GUI-dependent calls a virtual display
- Standalone PyQGIS Scripts and Headless Execution — the QgsApplication initialisation the container wraps
- Continuous Integration for QGIS Projects — running these images in an automated test pipeline