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.
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
# 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
#!/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
"""
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:
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:
#!/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-servernumasks it to pick a free display number automatically instead of hard-coding:99. This prevents theserver already active for display 99failure that plagues CI runners executing several containers or steps concurrently.--server-argsforwards options straight to Xvfb. The important one is the screen geometry:-screen 0 1024x768x24sets 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_PLATFORMbefore invoking Xvfb. Leaving it set silently reverts you to the null buffer and defeats the whole exercise. - Prefer
xvfb-run -afor 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
xdpyinfoloop when starting Xvfb by hand — connecting too early yields intermittent “cannot open display” errors. - Install
libgl1-mesa-drialongsidexvfbso 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.
Related
- Containerizing QGIS with Docker — parent guide covering the full container build, from base image to runtime configuration
- Building a Minimal QGIS Docker Image for Automation — the lean base image this page extends with Xvfb
- Continuous Integration for QGIS Projects — wiring
xvfb-runinto CI runners so rendering tests pass in the pipeline - Exporting a Print Layout to PDF with QgsLayoutExporter — the layout export use case that most often forces the move from offscreen to a real display