Running Headless QGIS with Xvfb in Docker

Run GUI-dependent QGIS code in a container with no display — when the offscreen Qt platform is not enough, use Xvfb and xvfb-run to give QGIS a virtual X…

TL;DR: QT_QPA_PLATFORM=offscreen covers most PyQGIS, but some rendering, layout and print paths still need a real X server. Install xvfb in the image and either wrap your command in xvfb-run -a, or start Xvfb :99 & and export DISPLAY=:99 for a shared, long-lived server.

This page is part of the Containerizing QGIS with Docker guide, itself part of the broader Headless Automation, CI/CD & Testing for PyQGIS reference. It assumes you already have a working QGIS image — if not, start with Building a Minimal QGIS Docker Image for Automation.

Why a Container Sometimes Still Needs a Display

QGIS is built on Qt, and Qt insists on loading a platform plugin before it will construct so much as a QImage. In a container there is no monitor, no X server and no $DISPLAY, so the default xcb plugin aborts with the familiar qt.qpa.plugin: could not connect to display message and the process dies before any of your code runs.

The first line of defence is the offscreen platform plugin, which renders into an in-memory buffer and needs no display at all. It is fast, dependency-free and correct for almost everything: loading layers, iterating features, running Processing algorithms, reprojecting data and computing statistics. For the workflows described in Initializing QgsApplication in a Headless Environment, offscreen is all you ever touch.

The friction appears at the rendering and print-layout edges of the API. Certain QgsLayout, QgsLayoutExporter and legacy map-render code paths — particularly on older Qt builds, or when a plugin pulls in a widget as a side effect — assume a connectable X server rather than the null buffer that offscreen provides. When they hit that assumption they either raise a platform-plugin error or silently produce a blank raster. That is the signal to give the container a real, if invisible, display: Xvfb, the X virtual framebuffer.

Decision: Offscreen or Xvfb?

Work through this before adding weight to your image. Xvfb is a genuine dependency and a background process, so only take it on when offscreen demonstrably falls short.

Offscreen versus Xvfb decision diagram A flowchart. Start by setting QT_QPA_PLATFORM to offscreen. If the code only does geoprocessing, offscreen is enough. If it renders maps or exports layouts, test under offscreen: if it works, keep offscreen; if it errors or produces blank output, install Xvfb and wrap the command in xvfb-run. Set QT_QPA_PLATFORM=offscreen Code renders maps or exports layouts? no Offscreen is enough yes Works under offscreen in a test? yes Keep offscreen no / blank Install xvfb; run under xvfb-run -a Real display available

Complete Runnable Setup

The three snippets below form one working setup: a Dockerfile that installs Xvfb, an entrypoint that wraps every command in xvfb-run, and a small PyQGIS script whose layout export is exactly the kind of code that needs a display.

1. Dockerfile — install Xvfb

dockerfile
# Builds on an image that already has QGIS installed. See the companion
# guide "Building a Minimal QGIS Docker Image for Automation".
FROM qgis/qgis:release-3_28

# Xvfb provides a virtual X server; the mesa driver lets Qt render without a GPU.
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        xvfb \
        xauth \
        libgl1-mesa-dri \
    && rm -rf /var/lib/apt/lists/*

# Offscreen remains the default; the entrypoint upgrades to Xvfb when needed.
ENV QT_QPA_PLATFORM=offscreen

WORKDIR /work
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY export_layout.py /work/export_layout.py
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["python3", "/work/export_layout.py"]

2. entrypoint.sh — wrap the command in xvfb-run

bash
#!/usr/bin/env bash
# Give every container command a virtual X server via xvfb-run.
#   -a                : auto-select a free server number (avoids :99 clashes)
#   --server-args     : 1024x768 canvas, 24-bit colour depth
set -euo pipefail

# When a display is needed, Qt must NOT use the offscreen plugin, or it will
# ignore the X server we just started. Unset it so xcb picks up $DISPLAY.
unset QT_QPA_PLATFORM

exec xvfb-run -a \
    --server-args="-screen 0 1024x768x24" \
    "$@"

3. export_layout.py — code that actually needs the display

python
"""
export_layout.py

Render a print layout to PNG. QgsLayoutExporter drives the paint pipeline,
which is one of the QGIS code paths that can require a connectable X server
rather than the null offscreen buffer. Run this under xvfb-run.
"""
from __future__ import annotations

import sys

from qgis.core import (
    QgsApplication,
    QgsLayout,
    QgsLayoutExporter,
    QgsLayoutItemLabel,
    QgsLayoutPoint,
    QgsProject,
    QgsUnitTypes,
)


def build_layout(project: QgsProject) -> QgsLayout:
    """Create a minimal one-page layout carrying a single label."""
    layout = QgsLayout(project)
    layout.initializeDefaults()  # gives us an A4 page to draw on

    label = QgsLayoutItemLabel(layout)
    label.setText("Rendered inside a headless container")
    label.adjustSizeToText()
    label.attemptMove(QgsLayoutPoint(15, 15, QgsUnitTypes.LayoutMillimeters))
    layout.addLayoutItem(label)
    return layout


def export_png(layout: QgsLayout, out_path: str) -> None:
    """Rasterise the layout — this is where a real display is exercised."""
    exporter = QgsLayoutExporter(layout)
    settings = QgsLayoutExporter.ImageExportSettings()
    settings.dpi = 150

    result = exporter.exportToImage(out_path, settings)
    if result != QgsLayoutExporter.Success:
        raise RuntimeError(f"Layout export failed with code {result}")
    print(f"Wrote {out_path}")


def main() -> int:
    """Bootstrap QGIS, build a layout and export it to PNG."""
    qgs = QgsApplication([], False)
    qgs.initQgis()
    try:
        project = QgsProject.instance()
        layout = build_layout(project)
        export_png(layout, "/work/out.png")
    finally:
        qgs.exitQgis()
    return 0


if __name__ == "__main__":
    sys.exit(main())

Build and run it with:

bash
docker build -t qgis-xvfb .
docker run --rm -v "$PWD/out:/work/out" qgis-xvfb

Alternative: an explicit long-lived Xvfb

xvfb-run starts and stops a server on every invocation, which is ideal for one-shot batch jobs. For a long-running service that renders many layouts, or when you want a single stable DISPLAY shared across several processes, start Xvfb once and export the variable yourself:

bash
#!/usr/bin/env bash
# Long-lived alternative: one Xvfb for the whole container lifetime.
set -euo pipefail

unset QT_QPA_PLATFORM
Xvfb :99 -screen 0 1024x768x24 -nolisten tcp &
XVFB_PID=$!
export DISPLAY=:99

# Give the server a moment to create the socket before Qt connects.
until xdpyinfo -display :99 >/dev/null 2>&1; do sleep 0.1; done

# Run the real workload; every child process inherits DISPLAY=:99.
python3 /work/export_layout.py
STATUS=$?

kill "$XVFB_PID"
exit "$STATUS"

Architecture Breakdown

QT_QPA_PLATFORM=offscreen vs Xvfb — what each provides

These are two different answers to the same question: how does Qt initialise without a monitor?

QT_QPA_PLATFORM=offscreen selects a Qt platform plugin that renders into an internal, memory-only buffer. Nothing outside the process is involved — there is no X server, no socket, no $DISPLAY. It is the lightest possible option and the correct default. Its limitation is that it is not a full display: a handful of QGIS routines probe for or connect to an X server directly, and against offscreen those probes fail or return empty pixels.

Xvfb runs a genuine X11 server that draws into an off-screen framebuffer in RAM. Qt talks to it through the normal xcb plugin exactly as it would to a physical monitor, so every code path that expects a real display is satisfied. The trade-off is weight: an extra package, an extra process and a socket to wait on. The rule of thumb is offscreen until proven insufficient, Xvfb thereafter — and crucially, when you switch to Xvfb you must unset QT_QPA_PLATFORM, otherwise Qt keeps using the offscreen plugin and ignores the server you started.

xvfb-run semantics — -a and --server-args

xvfb-run is a wrapper script that automates the whole lifecycle: it starts an Xvfb server, sets DISPLAY to point at it, runs your command, then shuts the server down and returns your command’s exit status. Two arguments matter most:

  • -a / --auto-servernum asks it to pick a free display number automatically instead of hard-coding :99. This prevents the server already active for display 99 failure that plagues CI runners executing several containers or steps concurrently.
  • --server-args forwards options straight to Xvfb. The important one is the screen geometry: -screen 0 1024x768x24 sets a 1024×768 canvas at 24-bit colour depth. Depth matters — some rendering fails or degrades at the default 8-bit depth, so 24 (or 16) is the safe choice for QGIS. Size the canvas at least as large as any image you intend to render.

Because xvfb-run propagates the wrapped command’s exit code, it drops cleanly into CI: a failing test still fails the pipeline. That makes it the natural choice for Continuous Integration for QGIS Projects.

The DISPLAY environment variable

DISPLAY is how every X client — Qt included — finds its server. The value :99 means “the X server with display number 99 on this host, via its local Unix socket.” When you run Xvfb explicitly you are responsible for exporting DISPLAY yourself so child processes can connect; xvfb-run sets it for you and unsets it again on exit. Two failure modes are common: exporting DISPLAY while Xvfb has not finished creating its socket (fixed by the xdpyinfo wait loop above), and leaving QT_QPA_PLATFORM=offscreen set, which makes Qt ignore DISPLAY entirely. Check both when a container reports it cannot open a display it appears to have started.

Production Best Practices

  • Default to offscreen; escalate to Xvfb only on a proven failure. Keep the extra package and process out of images that never render pixels.
  • Always unset QT_QPA_PLATFORM before invoking Xvfb. Leaving it set silently reverts you to the null buffer and defeats the whole exercise.
  • Prefer xvfb-run -a for one-shot jobs so concurrent CI steps never collide on a display number.
  • Set colour depth explicitly to 24 via --server-args="-screen 0 1024x768x24"; the low default depth causes subtle rendering artefacts.
  • Wait for the socket with an xdpyinfo loop when starting Xvfb by hand — connecting too early yields intermittent “cannot open display” errors.
  • Install libgl1-mesa-dri alongside xvfb so Qt has a software OpenGL fallback for any GL-backed rendering.
  • Pin QGIS and Xvfb versions in the image so the display behaviour is reproducible across rebuilds, exactly as you would pin any other dependency.