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.

Headless map-render decision flow Diagram comparing three rendering paths. QGIS print layout export requires xvfb-run to supply a virtual DISPLAY. Mapnik render_to_file needs no display. Contextily with matplotlib produces a basemap figure without any X server. Need a map image in headless CI? QGIS print layout needs a DISPLAY → xvfb-run full symbology Mapnik no display needed render_to_file XML stylesheet contextily + mpl no X server at all Agg backend basemap + overlay

Prerequisites

  • A Debian-based container (Xvfb ships as xvfb in 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.

DOCKERFILE
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.

Bash
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.

Python
# 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.

Python
# 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.

Python
# 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 pyplot before matplotlib.use("Agg"). The backend locks on first import; calling use() 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 always exitQgis() in a finally.
  • Undersized Xvfb screen. A -screen 0 640x480x8 default clips large layouts and posterizes color. Use a geometry at least as large as your layout at 24-bit depth.
  • Leaking Xvfb processes. Calling Xvfb manually without cleanup leaves zombie servers across CI runs. Prefer xvfb-run, which owns the lifecycle.

Verification

Confirm the render succeeds under the virtual display and that a non-empty raster lands on disk:

Bash
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.