Run PyQGIS in a Docker Container
"It works on my machine" has a specific meaning in geospatial work: your PROJ has the grid file that the server's PROJ does not, so the same reprojection lands two metres away. QGIS, GDAL and PROJ are a stack, not three independent packages, and the only reliable way to run the same analysis next year is to freeze the whole stack. A container does that in about fifteen lines.
This recipe belongs to Headless QGIS and Server Automation. It covers choosing a base image, adding your own dependencies, mounting data and scripts, running with no display, and the two file-permission problems that catch everyone on the first run.
Prerequisites
- Docker (or Podman, which accepts the same files) on the machine that will run the job.
- A PyQGIS script that already runs headlessly — see Headless QGIS and Server Automation for the initialisation pattern.
- Somewhere to put input data and collect output on the host.
A minimal image
FROM qgis/qgis:release-3_34
ENV QT_QPA_PLATFORM=offscreen \
PYTHONUNBUFFERED=1 \
XDG_RUNTIME_DIR=/tmp/runtime
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt
COPY nightly_export.py .
ENTRYPOINT ["python3", "/app/nightly_export.py"]
Breakdown: The official qgis/qgis images carry QGIS with its Python bindings and a matched GDAL and PROJ; pinning release-3_34 rather than latest is the entire point of the exercise. QT_QPA_PLATFORM=offscreen set in the image means no caller has to remember it. PYTHONUNBUFFERED=1 makes log lines appear as they happen rather than in a burst when the process ends, which matters when you are watching a job that has been running for ten minutes. --break-system-packages is needed because the image's Python is externally managed; installing into the system interpreter is correct here precisely because the container is the environment. XDG_RUNTIME_DIR silences a Qt warning that otherwise decorates every log line.
Build and run it
docker build -t nightly-export:3.34 .
docker run --rm \
-v /srv/gis/data:/data:ro \
-v /srv/gis/output:/out \
-e QGIS_AUTH_PASSWORD_FILE=/run/secrets/qgis_master \
--user "$(id -u):$(id -g)" \
nightly-export:3.34 --source /data/readings.gpkg --out /out
Breakdown: --rm deletes the container when it exits, so a nightly job does not accumulate hundreds of dead containers. Mounting the input :ro makes an accidental write impossible, which is worth the six characters. --user "$(id -u):$(id -g)" is the fix for the most common first-run complaint: without it the container runs as root and every output file lands on the host owned by root. Anything the script needs to know arrives as arguments after the image name, because the ENTRYPOINT is the interpreter and script.
Keep PROJ honest
The reason two machines disagree about a coordinate is almost always PROJ's transformation grids. The base image ships the standard set; anything national — the Ordnance Survey's OSTN15, Germany's BeTA2007, a state-specific NADCON grid — has to be added deliberately.
RUN mkdir -p /usr/share/proj && \
curl -fsSL -o /usr/share/proj/uk_os_OSTN15_NTv2_OSGBtoETRS.tif \
https://cdn.proj.org/uk_os_OSTN15_NTv2_OSGBtoETRS.tif
ENV PROJ_NETWORK=OFF
Breakdown: Downloading the grid at build time bakes it into the image, so the container is self-contained and reproducible. PROJ_NETWORK=OFF then forbids PROJ from silently fetching grids over the network at run time — which sounds convenient until a firewall blocks it and the transformation quietly falls back to a less accurate path. Fail loudly on a missing grid instead; the accuracy difference is metres, and it is invisible in the output. The consequences of getting this wrong are laid out in Handling Missing CRS in PyQGIS.
Composing it into a scheduled job
For a job with a database and a few environment variables, a compose file is easier to read than a long docker run:
services:
nightly-export:
image: nightly-export:3.34
volumes:
- /srv/gis/data:/data:ro
- /srv/gis/output:/out
- qgis-profile:/root/.local/share/QGIS
environment:
QGIS_AUTH_PASSWORD_FILE: /run/secrets/qgis_master
PGHOST: db.example.org
secrets:
- qgis_master
volumes:
qgis-profile:
secrets:
qgis_master:
file: ./secrets/qgis_master.txt
Breakdown: The named qgis-profile volume persists the QGIS profile — plugins, saved connections and the authentication database — across runs, so a credential stored once survives. The master password arrives as a mounted secret rather than an environment variable, which keeps it out of docker inspect. Everything else is deliberately stateless: delete the container, keep the volume, and the next run behaves identically. The scheduler then only has to call docker compose run --rm nightly-export, which is covered in Schedule PyQGIS Scripts with cron.
Order the Dockerfile so rebuilds are fast
Every instruction in a Dockerfile produces a layer, and Docker reuses cached layers until the first one whose inputs changed. Put the things that change rarely first and the thing that changes every commit last, and a rebuild after editing your script takes a second instead of two minutes.
FROM qgis/qgis:release-3_34
ENV QT_QPA_PLATFORM=offscreen PYTHONUNBUFFERED=1
# Changes rarely — cached across almost every build.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Changes when a dependency is added — cached while requirements.txt is untouched.
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt
# Changes on every commit — always rebuilt, but it is the cheapest layer.
COPY src/ /app/src/
ENTRYPOINT ["python3", "/app/src/nightly_export.py"]
Breakdown: Copying requirements.txt on its own, before the source, is the key move: editing the script invalidates only the final COPY, while the expensive pip install layer stays cached. Combining apt-get update with the install and the cleanup in a single RUN matters for size as well as correctness — separate instructions would leave the package lists inside an earlier layer where deleting them later cannot reclaim the space. --no-install-recommends typically saves a few hundred megabytes of packages nothing in the job uses.
Two habits complete the picture. Add a .dockerignore listing .git, test data and any local virtual environment, because everything else in the directory is sent to the daemon as build context on every build. And pin what you can: a base image tag rather than latest, and version specifiers in requirements.txt, so the image you build in six months is the image you are running today.
QGIS version compatibility
The examples target QGIS 3.34 LTR in the official image.
| Image tag | QGIS | Notes |
|---|---|---|
| qgis/qgis:release-3_28 | 3.28 LTR | Python 3.9; --break-system-packages not needed. |
| qgis/qgis:release-3_34 | 3.34 LTR | Baseline for this page. |
| qgis/qgis:release-3_40 | 3.40 | Newer GDAL and PROJ; re-verify any national grid transformations. |
| qgis/qgis:latest | development | Rebuilt continuously — never pin production to it. |
The qgis/qgis images target testing and include development tooling. For a slimmer production image, install qgis-python from the QGIS repository onto a plain distribution base and skip the desktop package entirely.
Troubleshooting
- Output files are owned by root. Add
--user "$(id -u):$(id -g)", and make sure the mounted output directory is writable by that user. - "could not connect to display".
QT_QPA_PLATFORM=offscreenis missing, or something imported Qt before it was set. - "algorithm not found".
Processing.initialize()was not called, or a provider plugin is not in the profile the container is using. - The image is enormous. The QGIS desktop package pulls in a large dependency tree. Install only
qgis-pythonandpython3-qgisfor a leaner image, or use a multi-stage build. - The container cannot resolve the database host. It has its own network namespace; use the compose network, or
--network hoston Linux when the database is reached through the host's own routes. - A reprojection differs from the desktop result. The grid files differ. Print
pyproj's or PROJ's data directory contents in both environments and compare.
Conclusion
A PyQGIS container is a pinned base image, your dependencies, your script and an offscreen Qt platform — plus two mounts and a user id so the results land on the host with the right ownership. Pin the QGIS tag, bake in the transformation grids you rely on, persist the profile in a named volume when credentials are involved, and the job you run tonight will produce the same numbers in a year.
Frequently Asked Questions
Can I run the QGIS desktop GUI from the container? Yes, by forwarding X11 or using a VNC-enabled image, but that is a debugging convenience rather than a deployment strategy. Automated jobs should stay offscreen.
Do I need the full QGIS package?
No. python3-qgis and its dependencies are enough for scripting and rendering. Dropping the desktop package roughly halves the image.
How do I install a plugin into the image?
Copy it into the profile's python/plugins directory during the build and enable it in the profile's settings file — or, better, install the plugin's algorithms as a Processing provider your script registers directly, as described in Processing Provider Plugins.
Is Podman a drop-in replacement? For these files, yes. Rootless Podman also removes the file-ownership problem, since container root maps to your user on the host.
How do I test the image before scheduling it? Run it once by hand with the real mounts and a small input, and check both the output and the exit code. A job that has never been run manually should not be given to a scheduler.