Running QGIS and Mapnik Headless with Xvfb
QGIS and, in some code paths, Mapnik expect an X11 display because they were built for desktop use — their rendering backends initialize graphics contexts that assume a DISPLAY exists. In a CI runner or a scheduled container there is no display, so a naive qgis_process call dies with could not connect to display or a segfault deep in Qt. This guide, part of the Headless PDF Rendering in Docker Containers workflow, shows how xvfb-run supplies a virtual framebuffer, how to export a QGIS print layout with PyQGIS, when Mapnik needs no display at all, and when a lighter contextily stack lets you skip Xvfb entirely.
Prerequisites
- A Debian-based container (Xvfb ships as
xvfbin Debian/Ubuntu repos) - QGIS 3.28+ installed from the official repository, or
mapnik/python-mapnik - Python 3.10+ with PyQGIS bindings on the path (set by
QGIS_PREFIX_PATH) - Familiarity with how the exported PNG is later embedded — see Automated Static Map Generation from GeoJSON for the downstream figure workflow
Step 1: Install Xvfb and the GIS Renderer
Xvfb (X Virtual Framebuffer) is an X11 server that draws into memory instead of a screen. Install it alongside QGIS or Mapnik in the container.
RUN apt-get update && apt-get install --no-install-recommends -y \
xvfb \
qgis \
python3-qgis \
&& rm -rf /var/lib/apt/lists/*
ENV QGIS_PREFIX_PATH=/usr
ENV QT_QPA_PLATFORM=offscreen
QT_QPA_PLATFORM=offscreen is the modern lighter alternative to Xvfb for pure Qt rendering — but QGIS’s map canvas and some layout item painters still touch an X display, so keep Xvfb available as the reliable fallback. QGIS_PREFIX_PATH tells PyQGIS where the installed data and providers live.
Step 2: Wrap the Export in xvfb-run
xvfb-run starts a throwaway Xvfb instance, sets DISPLAY for the child process, and tears it down on exit — no manual server lifecycle. Give it an explicit screen geometry so the render surface is large enough.
xvfb-run --auto-servernum \
--server-args="-screen 0 1920x1080x24" \
python export_layout.py --project atlas.qgz --layout "Flood Map" --out map.png
--auto-servernum picks a free display number, which matters when several renders run concurrently in the same runner. The x24 depth gives full 24-bit color so map symbology is not posterized.
Step 3: Export a QGIS Print Layout Headlessly with PyQGIS
Initialize a QgsApplication in GUI-less mode, load the project, find the print layout by name, and export it. The application must be started before any QGIS objects are constructed and cleanly exited afterward.
# export_layout.py
from __future__ import annotations
import argparse
from qgis.core import (
QgsApplication, QgsProject, QgsLayoutExporter,
)
def export_layout(project_path: str, layout_name: str, out_png: str) -> None:
qgs = QgsApplication([], False) # False = no GUI
qgs.initQgis()
try:
project = QgsProject.instance()
if not project.read(project_path):
raise RuntimeError(f"failed to open {project_path}")
manager = project.layoutManager()
layout = manager.layoutByName(layout_name)
if layout is None:
raise RuntimeError(f"layout '{layout_name}' not found")
exporter = QgsLayoutExporter(layout)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 300 # print resolution for report embedding
result = exporter.exportToImage(out_png, settings)
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"export failed with code {result}")
finally:
qgs.exitQgis()
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--project", required=True)
p.add_argument("--layout", required=True)
p.add_argument("--out", required=True)
a = p.parse_args()
export_layout(a.project, a.layout, a.out)
Setting settings.dpi = 300 produces a print-resolution raster suitable for embedding into a PDF. This mirrors the DPI discipline in Converting QGIS Layout Templates to Automated CSS Grids, where the exported layout becomes a CSS-driven page.
Step 4: Render with Mapnik as a Pure-Render Alternative
Mapnik’s render_to_file writes directly to a raster and needs no display, which makes it a lighter choice when you do not need QGIS’s project symbology. You supply an XML stylesheet and a bounding box.
# render_mapnik.py
from __future__ import annotations
import mapnik
def render_map(stylesheet: str, out_png: str, bbox: tuple[float, float, float, float]) -> None:
m = mapnik.Map(1600, 1200)
mapnik.load_map(m, stylesheet) # references shapefiles / PostGIS in XML
m.zoom_to_box(mapnik.Box2d(*bbox)) # (minx, miny, maxx, maxy)
mapnik.render_to_file(m, out_png, "png256")
if __name__ == "__main__":
render_map("style.xml", "mapnik_out.png", (-122.6, 45.4, -122.4, 45.6))
No xvfb-run wrapper is required here — Mapnik draws with AGG/Cairo entirely off-screen. Reserve QGIS for cases where you must reuse an existing .qgz project and its layered symbology.
Step 5: Skip Xvfb Entirely with contextily
When the figure is “a basemap plus a GeoDataFrame overlay,” you rarely need QGIS or Mapnik at all. matplotlib on the non-interactive Agg backend plus contextily for tiles renders without any X server, which keeps the container small and the pipeline simple.
# render_contextily.py
from __future__ import annotations
import matplotlib
matplotlib.use("Agg") # headless backend — must be set before pyplot import
import matplotlib.pyplot as plt
import geopandas as gpd
import contextily as cx
def render_overlay(geojson_path: str, out_png: str) -> None:
gdf = gpd.read_file(geojson_path).to_crs(epsg=3857)
ax = gdf.plot(figsize=(10, 8), edgecolor="black", alpha=0.6)
cx.add_basemap(ax, source=cx.providers.CartoDB.Positron)
ax.set_axis_off()
plt.savefig(out_png, dpi=200, bbox_inches="tight")
plt.close()
If every map in your reports is an overlay figure, standardize on this path and drop Xvfb and QGIS from the image entirely — a decision that also simplifies the dependency install covered in Installing WeasyPrint Dependencies in Slim Docker Images.
Key Parameters / Configuration Reference
| Setting | Applies to | Value | Effect |
|---|---|---|---|
QGIS_PREFIX_PATH |
PyQGIS | /usr |
Locates QGIS providers and data before initQgis() |
QT_QPA_PLATFORM |
Qt/QGIS | offscreen |
Lets pure-Qt paths skip a real display |
--server-args |
xvfb-run | -screen 0 1920x1080x24 |
Virtual framebuffer size and 24-bit color depth |
--auto-servernum |
xvfb-run | flag | Picks a free display for concurrent renders |
settings.dpi |
QgsLayoutExporter | 300 |
Output raster resolution for print embedding |
matplotlib.use() |
contextily path | "Agg" |
Non-interactive backend; no X server needed |
Common Pitfalls
- Importing
pyplotbeforematplotlib.use("Agg"). The backend locks on first import; callinguse()afterward is ignored and the process tries to open a display. Set the backend at the very top of the module. - Constructing QGIS objects before
initQgis().QgsProject.instance()before the application initializes returns a half-built object and segfaults. Always init first, and alwaysexitQgis()in afinally. - Undersized Xvfb screen. A
-screen 0 640x480x8default clips large layouts and posterizes color. Use a geometry at least as large as your layout at 24-bit depth. - Leaking Xvfb processes. Calling
Xvfbmanually without cleanup leaves zombie servers across CI runs. Preferxvfb-run, which owns the lifecycle.
Verification
Confirm the render succeeds under the virtual display and that a non-empty raster lands on disk:
xvfb-run -a python export_layout.py --project atlas.qgz --layout "Flood Map" --out map.png
python - <<'PY'
from PIL import Image
im = Image.open("map.png")
assert im.size[0] > 100 and im.size[1] > 100, "raster too small — render likely failed"
assert im.getextrema() != ((255, 255), (255, 255), (255, 255)), "image is blank white"
print("ok", im.size, im.mode)
PY
The extrema check catches the classic silent failure where the exporter writes an all-white canvas because layers never painted onto the virtual surface.
Related
- Headless PDF Rendering in Docker Containers — parent section covering the containerized rendering workflow end to end
- Installing WeasyPrint Dependencies in Slim Docker Images — the sibling guide for the PDF renderer that consumes these map images
- Automated Static Map Generation from GeoJSON — building the map figures this pipeline exports
- Parallel Batch Rendering with ProcessPoolExecutor — fanning these renders out across worker processes