Headless QGIS and Server Automation

The script that works beautifully in the Python console has one dependency it never told you about: a person. Someone opened QGIS, loaded a project, and clicked run. Moving that script onto a server means removing the person — and with them the display, the interactive prompts, the assumption that the current project is loaded, and the habit of noticing when something looks wrong.

This guide sits inside PyQGIS Fundamentals & Environment Setup and covers everything between "my script works" and "my script runs every night without me". It builds on Running Python Scripts Outside QGIS Desktop, which explains how to initialise QGIS from a plain Python process; here we take that starting point onto a machine with no screen and keep it running reliably.

What a headless QGIS process is made ofFrom the bottom up: a server with no display server running, then QGIS initialised with the offscreen platform, then the processing registry with native and third-party providers loaded, then your script, and at the top the outputs it writes and the log stream a scheduler collects. A side note marks the two things that must be supplied explicitly because no desktop session provides them: the QGIS prefix path and the authentication master password.The desktop supplied five things — now you doserver · no X display · QT_QPA_PLATFORM=offscreenQgsApplication — prefix path, initQgis()Processing registry — native + GDAL providersyour scriptmust be suppliedprefix path · profile dirauth master passwordabsolute pathswhat comes outoutputs on disk or in a tablestructured log linesan exit code that means it

What you will learn

The four pages under this guide take one step each. Use the qgis_process Command-Line Runner covers the case where no Python is needed at all. Run PyQGIS in a Docker Container pins the environment so the run is reproducible. Schedule PyQGIS Scripts with cron deals with the environment a scheduler does not give you. Handle Errors and Logging in Unattended Scripts is what turns a silent failure into a message someone acts on.

Initialising QGIS with no display

Qt needs a platform plugin even when nothing will be drawn. On a server there is no X display, so tell Qt to use the offscreen platform and QGIS never asks for one.

import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from qgis.core import QgsApplication

QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()

from processing.core.Processing import Processing
import processing
Processing.initialize()

# … work …

app.exitQgis()

Breakdown: The environment variable must be set before any Qt class is imported, which is why it comes before the qgis.core import. QgsApplication([], False) creates the application without a GUI — the second argument is what makes it headless. setPrefixPath() tells QGIS where its resources live; /usr is right for a Debian or Ubuntu package, /usr/local for a source build, and on Windows it is the QGIS installation directory. Processing.initialize() is separate from initQgis() and easy to forget; without it, processing.run() raises "algorithm not found" for every native algorithm. exitQgis() releases the providers cleanly, which matters in a long-lived process and not much in a one-shot script.

Rendering works under the offscreen platform too, so map image export runs on a headless box exactly as it does on a desktop — see Render a Layer to an Image Without the GUI.

Script or command-line runner

Not every automation needs Python. QGIS ships qgis_process, a command-line runner that executes any Processing algorithm directly, and for a single algorithm it removes the entire initialisation problem.

Choosing between qgis_process and a Python scriptA four-row comparison. A single algorithm is best run with qgis process. A fixed chain of algorithms can go either way, favouring a shell script for simple cases. Anything with conditional logic, per-feature work or error recovery needs a Python script. Layout and atlas export needs Python because no algorithm covers it.Reach for Python when the logic stops being a straight linethe taskqgis_processPython scriptone algorithm, fixed parametersbest choiceworks, more setupa fixed chain of algorithmsfine in a shell scripteasier to debugbranching, retries, per-feature worknot possiblebest choicelayout or atlas exportno algorithm for itonly choice

qgis_process run native:buffer -- \
  INPUT=/data/roads.gpkg\|layername=roads \
  DISTANCE=25 \
  OUTPUT=/data/output/roads_buffer.gpkg

Breakdown: Everything after -- is a parameter assignment matching the algorithm's parameter names — the same names a Python call uses, which is why processing.algorithmHelp("native:buffer") is the reference for both. The pipe in a GeoPackage data source must be escaped in a shell. qgis_process exits non-zero on failure, so it composes with set -e and with any scheduler that watches exit codes. The full treatment, including --json output and running models, is in Use the qgis_process Command-Line Runner.

Projects, paths and other desktop assumptions

Three habits from interactive work break silently on a server.

QgsProject.instance() is empty. No project is loaded unless you load one. Call QgsProject.instance().read("/srv/projects/flooding.qgs") explicitly, and remember that a project storing relative paths resolves them against the project file, not the working directory.

Relative paths resolve against the scheduler's working directory, which is rarely what you assumed. Make every path in an automated script absolute, or derive it from a configured root.

Layer names are not unique. mapLayersByName("roads")[0] works fine until a project gains a second layer called "roads", at which point it silently starts using the wrong one. Address layers by id in unattended code.

Two environment concerns join them: the QGIS profile directory determines where plugins and the authentication database live, and a server process gets a fresh, empty one unless told otherwise. If your job connects to PostGIS with a stored credential, both the profile and the authentication master password have to be supplied — see Connect to a PostGIS Database in PyQGIS.

Rendering maps with no screen

Nothing about map rendering requires a visible window, which surprises people who assume image export is a desktop-only feature. Under the offscreen platform the same rendering engine produces the same pixels, so a nightly job can publish finished maps rather than only data.

from qgis.core import QgsProject, QgsLayoutExporter

project = QgsProject.instance()
project.read("/srv/projects/flooding.qgz")

layout = project.layoutManager().layoutByName("Overview")
exporter = QgsLayoutExporter(layout)

settings = QgsLayoutExporter.PdfExportSettings()
settings.dpi = 300
settings.rasterizeWholeImage = False

result = exporter.exportToPdf("/srv/output/flooding.pdf", settings)
if result != QgsLayoutExporter.Success:
    raise RuntimeError(f"layout export failed with code {result}")

Breakdown: Reading the project explicitly is required — a headless process has no current project, and layoutByName() on an empty project returns None rather than raising, so the failure surfaces one line later as an unhelpful attribute error. rasterizeWholeImage = False keeps text and vectors as vectors in the PDF, which matters for print quality and file size; setting it to True is the fallback when blend modes or transparency render incorrectly. The exporter returns a status code, Success being zero, following the same convention as the raster calculator and the file writer.

Two practical cautions apply to headless rendering. Fonts must be installed in the environment that renders, not on your workstation — a missing font is silently substituted and the map comes out looking subtly wrong. And any layer whose source is a network service will be fetched at render time, so a job that renders a basemap needs network access and a timeout policy. The image-export equivalents are covered in Map Canvas Control and Image Export, and the atlas variant in Automating Atlas Map Series.

Making failure visible

An unattended script has exactly two ways to communicate: what it writes, and the exit code it returns. Both need attention.

Log with structure rather than prose — a line per unit of work, with counts and durations, so a human comparing two nights can see what changed. Route QGIS's own messages into the same stream by connecting to QgsApplication.messageLog().messageReceived, otherwise provider warnings vanish. Exit non-zero on any failure; a job that reports success after processing zero features is worse than one that crashes, because nobody investigates it.

The same failure, with and without instrumentationOn the left a log shows only a start line and a done line, and the run is marked successful even though nothing was written. On the right the same run logs the source count, the written count, the duration and a non-zero exit, so the drop from forty thousand features to zero is obvious at a glance.A job that cannot fail loudly will fail quietly for monthssilent script02:00 starting nightly export02:04 doneexit 0 — nobody looks againthe output file is emptyinstrumented script02:00 source=readings features=002:00 expected>1000 — abortingexit 2 — the scheduler alertsyesterday's output is untouched

A job skeleton worth copying

Most headless scripts end up with the same shape, and writing it deliberately once saves rediscovering it under pressure.

import os
import sys
import time
import logging

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from qgis.core import QgsApplication, QgsVectorLayer, QgsProject

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-7s %(message)s",
    stream=sys.stdout,
)
log = logging.getLogger("nightly")


def build_app():
    QgsApplication.setPrefixPath(os.environ.get("QGIS_PREFIX", "/usr"), True)
    app = QgsApplication([], False)
    app.initQgis()
    from processing.core.Processing import Processing
    Processing.initialize()
    return app


def main(app):
    started = time.monotonic()
    source = QgsVectorLayer(os.environ["SOURCE_URI"], "source", "ogr")
    if not source.isValid():
        log.error("source did not open: %s", os.environ["SOURCE_URI"])
        return 1

    log.info("source has %d features", source.featureCount())
    # … the actual work …
    log.info("finished in %.1fs", time.monotonic() - started)
    return 0


if __name__ == "__main__":
    application = build_app()
    try:
        sys.exit(main(application))
    except Exception:
        log.exception("run failed")
        sys.exit(1)
    finally:
        application.exitQgis()

Breakdown: Configuration arrives through environment variables rather than being edited into the file, so the same script runs against test and production data without a diff. build_app() is separated from main() so a test can construct the application once and call main() repeatedly. The try/except/finally at the bottom is doing three jobs at once: it guarantees a traceback reaches the log, it converts any escape into a non-zero exit code, and it releases QGIS even on failure so no process lingers holding database connections. Returning an integer from main() rather than calling sys.exit() inside it keeps the function testable.

Time, locale and the other quiet differences

Three environment details break headless runs in ways that are hard to attribute after the fact.

The locale decides how numbers parse and print. A server set to a locale using a comma as the decimal separator will write 1,5 where you expected 1.5, and a CSV consumer downstream will read it as two columns. Set LANG=C.UTF-8 in the job's environment and the problem disappears permanently.

The time zone decides what "yesterday" means. A job that runs at 02:00 local and filters on current_date - 1 produces different rows depending on the server's zone. Do date arithmetic in UTC, or pass the date in explicitly as an argument so a re-run over a past date is possible.

The QGIS profile decides which plugins and connections exist. A server process gets a fresh, empty profile unless QGIS_CUSTOM_CONFIG_PATH points at one. If your job depends on a provider plugin or a saved database connection, that variable is not optional — and neither is checking, at start-up, that what you depend on is actually present:

from qgis.core import QgsApplication

registry = QgsApplication.processingRegistry()
if registry.algorithmById("native:buffer") is None:
    raise RuntimeError("Processing did not initialise — check Processing.initialize()")

Breakdown: Asserting a known algorithm exists is a two-line smoke test that fails immediately and specifically, rather than a thousand lines later with a confusing "algorithm not found". The same shape works for a provider plugin: look up one of its algorithm ids and refuse to start without it.

Reproducing the environment

Two machines with "QGIS 3.34" can still disagree, because the GDAL, PROJ and Python versions underneath differ, and PROJ in particular decides whether a datum transformation is available. Pinning the whole stack is what containers are for: the official QGIS images fix every version, and the run becomes something you can reproduce in a year. That is the subject of Run PyQGIS in a Docker Container, which also covers mounting data and getting the PROJ grid files into the image.

If containers are not available, a virtual environment created with --system-site-packages against the system QGIS is the next best thing — the approach described in Virtual Environments for GIS.

Reaching data from a server

Data access is where a script that worked on a workstation most often stops working on a server, and the causes are mundane rather than technical.

Paths that existed on your machine do not exist on the server. A project referencing /home/you/data/roads.gpkg fails, and a project using relative paths fails differently — it resolves them against the project file, which may sit somewhere else entirely. Store the data root in an environment variable and build paths from it, or keep the project's paths relative and place the project in a known location relative to the data.

Network shares behave differently. SQLite-based formats — GeoPackage, SpatiaLite — need real file locking, which SMB and NFS mounts frequently emulate rather than implement. A job that reads from a share is usually fine; a job that writes to one can corrupt the file. Write locally and copy the finished output into place.

Credentials must arrive non-interactively. A stored PostGIS credential lives in the QGIS authentication database, which is unlocked by a master password. On a desktop QGIS prompts for it; on a server the process simply blocks. Point QGIS_AUTH_PASSWORD_FILE at a file readable only by the job's user, or read the credential from the environment and build the connection without the authentication database at all. Both approaches are set out in Connect to a PostGIS Database in PyQGIS.

Concurrency is a shared-data problem, not a QGIS problem. Two jobs writing the same GeoPackage will block or corrupt; two jobs writing the same PostGIS table will serialise safely. That difference, rather than dataset size, is usually what decides whether a project needs a database.

Key takeaways

  • Set QT_QPA_PLATFORM=offscreen before importing anything from Qt or QGIS, and construct QgsApplication([], False).
  • Processing.initialize() is a separate step from initQgis(); skipping it makes every native algorithm unavailable.
  • Use qgis_process for a single algorithm and Python for anything with branching, per-feature work or layout output.
  • Make every path absolute and load projects explicitly — a server has no current project and no reliable working directory.
  • Log counts, not prose, and exit non-zero on failure, so a scheduler can tell the difference between a good night and a bad one.
  • Pin the stack in a container when the result has to be reproducible across machines and months.

Frequently Asked Questions

How do I know the run used the QGIS version I think it did? Log it. Qgis.QGIS_VERSION printed at start-up alongside the GDAL and PROJ versions costs three lines and answers the question that comes up whenever two machines disagree about a coordinate or an algorithm's output.

Can one script serve both interactive and headless use? Yes, and it is worth arranging. Keep the work in functions that take explicit inputs and return values, put the QGIS bootstrap behind a if __name__ == "__main__": guard, and the same module can be imported into the Python console for exploration and executed by a scheduler unchanged.

Do I need QGIS Server to run PyQGIS on a server? No. QGIS Server is a web map service (WMS, WFS, WMTS); it is not required to run scripts. A headless script needs only the QGIS libraries and Python bindings, which the desktop package already provides.

Why does my script hang instead of finishing? Something is waiting for a user. A missing offscreen platform, a plugin that opens a dialog on load, or an authentication prompt for a stored credential will all block indefinitely. Run with a clean profile and supply the master password non-interactively.

Can I run several PyQGIS jobs concurrently on one machine? Yes, as separate processes with separate profile directories. One process should not host two QgsApplication instances, and sharing a profile risks two jobs writing the same settings file.

How much memory does a headless run need? The libraries themselves are modest — a few hundred megabytes. What dominates is the data: an algorithm streaming a GeoPackage stays flat, while a script that materialises every feature in a Python list scales with the dataset.

Do plugins work headlessly? Processing provider plugins do, if the profile that contains them is used and they are enabled. Plugins that build GUI elements at load time often do not, which is a good reason to keep algorithm code separate from interface code — see Processing Provider Plugins.

How do I pass configuration into a scheduled run? Environment variables for anything that differs between environments — data roots, database hosts, output directories — and command-line arguments for anything that differs between runs, such as a date. Keeping both out of the source means the same file runs in test and production, and a re-run over last Tuesday is one argument away.

Should the job write its output straight into the published location? No. Write to a temporary path and move the finished file into place once it is complete and validated. A rename within the same filesystem is atomic, so consumers never see a half-written file, and a failed run leaves yesterday's output untouched.

Is qgis_process slower than a Python script? Per call it pays the QGIS start-up cost, roughly a second or two. Running one algorithm, that is irrelevant; running a thousand in a loop, a single Python process that initialises once is dramatically faster.