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.

What is baked into the image and what is mounted at run timeThe image contains four stacked layers: a base operating system, PROJ and GDAL with their grid files, QGIS and its Python bindings, and the project's own Python dependencies plus the script. Outside the image, two host directories are mounted at run time: input data read-only and an output directory writable. A caption notes that everything inside the image is pinned by version and everything mounted changes between runs.Pin the stack, mount the datathe image — identical every runUbuntu 24.04PROJ 9.x + grid files · GDAL 3.8QGIS 3.34 LTR + python3-qgisrequirements.txt + your script/data — mounted read-onlysource layers from the hostchanges between runs/out — mounted writableresults survive the containerwatch the file ownershipAnything written inside the container and not mounted out is gone when it exits

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.

The same reprojection, with and without the grid fileTwo identical scripts transform the same point from a national grid to a global system. The container with the transformation grid installed lands on the true position. The container without it falls back to a coarse seven-parameter transformation and lands about two metres away, reporting no error at all.A missing grid file does not raise — it just moves your datagrid baked into the imagetrue positionaccurate to a few centimetresgrid missing, network offtruecomputedabout two metres out, exit code 0Assert the transformation you expect at start-up rather than trusting the default

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.

Layer order decides how long a rebuild takesIn the first ordering the source is copied before the dependencies are installed, so editing one line of the script invalidates the install layer and the rebuild reinstalls everything. In the second ordering the requirements file is copied and installed first, so a source edit invalidates only the last, cheapest layer.Same instructions, two very different rebuild timessource copied firstFROM qgis/qgis — cachedCOPY src/ — invalidatedpip install — rebuilt every timetwo minutes for a one-line editrequirements copied firstFROM qgis/qgis — cachedpip install — cachedCOPY src/ — the only rebuildabout a second

QGIS version compatibility

The examples target QGIS 3.34 LTR in the official image.

Image tagQGISNotes
qgis/qgis:release-3_283.28 LTRPython 3.9; --break-system-packages not needed.
qgis/qgis:release-3_343.34 LTRBaseline for this page.
qgis/qgis:release-3_403.40Newer GDAL and PROJ; re-verify any national grid transformations.
qgis/qgis:latestdevelopmentRebuilt 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=offscreen is 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-python and python3-qgis for 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 host on 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.