Headless Automation, CI/CD & Testing for PyQGIS

Engineering guide to running QGIS without a GUI: standalone PyQGIS scripts, Docker images, pytest-qgis testing, GitHub Actions CI, and packaging plugins for…

Every PyQGIS technique eventually has to leave the desktop. The script that reprojects a folder overnight, the plugin that ships to hundreds of analysts, the geoprocessing service behind an API — none of them run inside the QGIS window where you prototyped them. This guide covers everything that happens once PyQGIS code goes unattended: running it with no GUI, packaging its dependencies into containers, exercising it under an automated test suite, gating it through continuous integration, and shipping it to users. It is written for GIS and automation engineers who are comfortable with interactive QGIS work and now need reproducible, tested, deployable pipelines that behave identically on a laptop and on a build runner.

The through-line is determinism. A headless run has no human to click “OK” on a CRS-selection dialog, no canvas to reveal that a layer failed to load, and no console to eyeball a stack trace. Reproducibility has to be engineered in — through explicit application bootstrap, pinned dependency versions, assertions that fail loudly, and exit codes a build system can read. The subsystems here build directly on the PyQGIS core architecture and data handling foundation: the same QgsProject state, CRS machinery, and provider registry you use interactively are what a standalone process must initialise by hand.

The Delivery Pipeline

The diagram below traces a single change from a script on your machine to a published plugin. Each box maps to one section of this guide, and each arrow is a boundary where something can silently break unless you engineer for it.

Headless PyQGIS delivery pipeline A left-to-right pipeline of six stages. A local PyQGIS script feeds a headless QgsApplication running under Xvfb or Docker, which feeds a pytest-qgis test suite, which runs inside continuous integration on GitHub Actions across a QGIS version matrix, which produces a packaged plugin made of a metadata.txt file and a zip archive, which is uploaded to the QGIS Plugin Repository. Local PyQGIS script Headless QgsApplication Xvfb / Docker pytest-qgis test suite CI matrix GitHub Actions QGIS versions Packaged plugin metadata.txt + zip QGIS Plugin Repository Each boundary (arrow) is a place a headless run can fail silently without engineered checks

Why Headless Is Different

Inside QGIS Desktop, an enormous amount of state is configured for you before your first line of Python executes. The application object exists, the Processing framework is registered, PROJ has found its datum grids, the provider registry knows how to open a GeoPackage, and iface gives you a live handle on the canvas. A standalone process starts with none of that. It is a bare Python interpreter that happens to be able to import qgis.core, and until you build the runtime yourself, nearly every call returns empty results or raises.

This is why headless failures are so often silent rather than loud. A layer that will not load returns an invalid QgsVectorLayer rather than throwing; a missing PROJ database yields an identity transform rather than an error; an unregistered Processing provider makes processing.run() raise a bare QgsProcessingException with no hint that the framework was never initialised. Engineering for headless execution means asserting the conditions the desktop guarantees implicitly — that the application booted, that providers are present, that a layer is valid — and then wiring those assertions into a test suite and a CI gate so a regression is caught before it ships.

Standalone Scripts and Headless Execution

The first competency is booting QGIS in a plain Python process. A standalone script must set the prefix path, construct a QgsApplication with GUI mode disabled, call initQgis() to populate the provider registry, and — crucially, for anything using Processing — initialise the Processing framework separately. At shutdown it must call exitQgis() so the C++ core tears down providers and PROJ context in the correct order. Skipping the teardown is the classic cause of a segfault printed after your work has already succeeded. This is covered end to end in standalone PyQGIS scripts and headless execution.

python
from qgis.core import QgsApplication


def bootstrap() -> QgsApplication:
    """Initialise a headless QgsApplication with Processing available.

    Returns:
        The live application; keep the reference until exitQgis() is called.
    """
    QgsApplication.setPrefixPath("/usr", True)
    qgs = QgsApplication([], False)  # False: no GUI
    qgs.initQgis()

    from processing.core.Processing import Processing
    Processing.initialize()  # register native algorithms for headless use
    return qgs


app = bootstrap()
try:
    ...  # your automation here — reuses QgsProject/CRS handling from the core guide
finally:
    app.exitQgis()  # ordered teardown of providers and PROJ context

Containerizing QGIS with Docker

Booting QGIS is only reproducible if the QGIS underneath it is reproducible. A container pins the exact QGIS build, its GDAL and PROJ versions, and the datum grids, so a pipeline behaves identically on every machine. The practical work is choosing a base image, setting QT_QPA_PLATFORM=offscreen so Qt needs no display, exporting the correct PROJ_LIB/GDAL_DATA paths, and adding Xvfb only for the rare code path that touches real rendering. The containerizing QGIS with Docker guide covers minimal images and the Xvfb fallback in depth.

dockerfile
# Pin the QGIS version so every build is byte-for-byte reproducible
FROM qgis/qgis:release-3_34

ENV QT_QPA_PLATFORM=offscreen \
    PYTHONUNBUFFERED=1

WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt

COPY . .
# Smoke test: the image can import and boot QGIS
RUN python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION)"

Testing PyQGIS with pytest-qgis

Once the runtime is reproducible, it becomes testable. The pytest-qgis plugin boots a single QgsApplication per test session through its qgis_app fixture and exposes helpers such as qgis_new_project and a mock iface, so individual tests stay fast and isolated. You assert on the things headless code cannot show you interactively: that a layer is valid, that a feature count matches, that a transformed geometry equals an expected WKT. Those assertions are what make the testing PyQGIS with pytest-qgis suite the safety net for both standalone scripts and plugins.

python
from qgis.core import QgsVectorLayer


def test_layer_loads(qgis_app) -> None:
    """A packaged fixture layer loads and reports the expected feature count."""
    layer = QgsVectorLayer("tests/data/buildings.gpkg|layername=buildings",
                           "buildings", "ogr")
    assert layer.isValid(), "provider failed to open the GeoPackage"
    assert layer.featureCount() == 128

Continuous Integration for QGIS Projects

A test suite earns its keep only when it runs on every push, across every QGIS version you promise to support. Continuous integration executes the containerised suite in a job matrix — typically the oldest supported Long Term Release, the current LTR, and latest — so an API change that works on one version but breaks another is caught before a user hits it. This is where the plugins built under plugin development and UI integration get exercised against real QGIS builds rather than a developer’s single local install. The continuous integration for QGIS projects guide details the matrix strategy and caching.

yaml
name: tests
on: [push, pull_request]
jobs:
  pytest:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        qgis: ["release-3_28", "release-3_34", "latest"]
    container: qgis/qgis:${{ matrix.qgis }}
    env:
      QT_QPA_PLATFORM: offscreen
    steps:
      - uses: actions/checkout@v4
      - run: pip3 install pytest pytest-qgis
      - run: xvfb-run -a pytest -v   # xvfb-run covers rare rendering paths

Packaging and Distributing QGIS Plugins

The final stage turns a tested repository into something a user can install. A QGIS plugin is a folder with a metadata.txt manifest — name, qgisMinimumVersion, version, author, experimental — zipped so the top-level directory matches the plugin’s module name. That archive is uploaded to the QGIS Plugin Repository, where the manifest’s qgisMinimumVersion decides which desktops are offered the update. Getting the layout and manifest right is what the packaging and distributing QGIS plugins guide is about; ideally the zip is built by the same CI that just proved the plugin passes across the version matrix.

python
import zipfile
from pathlib import Path


def build_plugin_zip(plugin_dir: Path, out: Path) -> Path:
    """Zip a plugin folder so its top-level dir matches the module name.

    Args:
        plugin_dir: Directory containing metadata.txt and __init__.py.
        out: Destination .zip path.

    Returns:
        The written archive path.
    """
    assert (plugin_dir / "metadata.txt").is_file(), "metadata.txt is required"
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
        for path in plugin_dir.rglob("*"):
            if "__pycache__" in path.parts:
                continue
            zf.write(path, path.relative_to(plugin_dir.parent))
    return out

Cross-Cutting Concerns

Five concerns run through every stage of the pipeline, and getting them right is what separates a suite that catches regressions from one that reports green while broken.

Process exit codes. A build runner reads success or failure from the process exit code, nothing else. A standalone script must translate outcomes into codes explicitly with sys.exit(0) on success and a non-zero code on failure — and must do so after exitQgis(), because a segfault during teardown produces a non-zero code that masks whether the work itself succeeded. pytest handles this for you: any failed assertion or error yields a non-zero exit, which is precisely what the CI step keys on.

Surfacing logs in CI. QgsMessageLog.logMessage() writes to the QGIS message log, which has no visible panel in a headless run. Route it to stderr so CI captures it, and prefer categorised log messages over print() so failures carry context:

python
from qgis.core import QgsApplication, QgsMessageLog, Qgis


def echo_qgis_log_to_stderr() -> None:
    """Forward QgsMessageLog entries to stderr so CI logs capture them."""
    import sys

    def _handler(message: str, tag: str, level: Qgis.MessageLevel) -> None:
        print(f"[{tag}] {message}", file=sys.stderr)

    QgsApplication.messageLog().messageReceived.connect(_handler)
    QgsMessageLog.logMessage("logging wired to stderr", "CI", Qgis.Info)

Version guards across the matrix. The whole point of a version matrix is to exercise Qgis.QGIS_VERSION_INT branches on the versions where they actually differ. Guard API calls that changed between releases with the integer constant — if Qgis.QGIS_VERSION_INT >= 33400: — rather than parsing the version string, and let CI prove both branches. This is the same version-compatibility discipline detailed in the PyQGIS core architecture guide, now enforced automatically instead of by hand.

Headless environment variables. Two variables make or break an unattended run. QT_QPA_PLATFORM=offscreen tells Qt to use the offscreen platform plugin so no X server is required; without it, constructing widgets or a map canvas aborts with a “could not connect to display” error. PROJ_LIB (and GDAL_DATA) must point at the datum-grid and driver-support files, or CRS transforms silently degrade to identity — the same silent-misalignment failure mode that plagues interactive scripts, only now with no map to reveal it.

Determinism of test data. Ship small, version-controlled fixture datasets alongside the tests rather than pulling from a network source at test time. A remote fetch introduces a failure mode the code under test does not own, and a floating dataset makes an assertion like featureCount() == 128 unreproducible.

Performance Considerations

Headless pipelines are judged on wall-clock time per push, and three levers dominate.

  • Docker layer caching. Order the Dockerfile from least to most volatile: base image, then system packages, then pip install -r requirements.txt, then finally COPY . .. Because the source changes on every commit but dependencies rarely do, this ordering lets CI reuse the cached dependency layers and rebuild only the final copy, cutting image build time from minutes to seconds.
  • Parallel pytest workers. pytest -n auto (via pytest-xdist) spreads tests across CPU cores. The catch is the shared qgis_app session fixture: each worker boots its own QgsApplication, so ensure tests do not share on-disk fixture files that they also mutate. For a suite dominated by many independent layer-loading tests, parallelism is close to linear.
  • Pinning QGIS versions. Pin to explicit release tags such as qgis/qgis:release-3_34 rather than latest. latest moves without warning, which both breaks reproducibility and invalidates the Docker cache on unrelated days, forcing a full rebuild. Pinning keeps the matrix stable and the cache warm.
  • Reuse the application across the session, not per test. Booting QgsApplication and initialising Processing is expensive. The qgis_app session fixture pays that cost once; do not construct a second application inside individual tests.

Conclusion

The distance between a working PyQGIS prototype and a deployable one is entirely about removing the human from the loop and replacing them with engineered guarantees. A standalone bootstrap replaces the desktop’s implicit application; a container replaces “works on my machine” with a pinned, reproducible runtime; a pytest-qgis suite replaces eyeballing the canvas with explicit assertions; a CI matrix replaces “it ran once on my QGIS” with proof across every version you support; and a well-formed package replaces manual installation with a repository users trust. Each stage feeds the next — the tests exercise the containerised runtime, CI runs the tests, and packaging ships what CI validated — so the pipeline is one system rather than five chores.

Teams that treat exit codes, log surfacing, version guards, and the offscreen/PROJ_LIB environment as first-class concerns rather than afterthoughts get the payoff headless automation promises: a change that goes green in CI is a change you can ship without watching it run.


Explore further