Headless PDF Rendering in Docker Containers
A spatial report that renders perfectly on an analyst’s laptop can produce blank map frames, missing glyphs, or a hard OSError the moment it runs inside a container — because weasyprint carries no text engine of its own and links against native Pango, Cairo, and fontconfig libraries that a stripped-down base image simply does not ship. This guide covers how to package a WeasyPrint-based reporting pipeline into a reproducible, slim, non-root Docker image that renders identical PDFs in local development and in a headless CI/CD runner, so the artifact you review is the artifact your pipeline ships.
Prerequisites
Before building the image, confirm your local stack and the versions you intend to pin into the container:
- Python 3.10+ with
weasyprint>=60.0,jinja2>=3.1, andpydyf>=0.8(WeasyPrint’s PDF writer) - Docker 24+ with BuildKit enabled (
DOCKER_BUILDKIT=1) for layer cache mounts - Native rendering stack: Pango, Cairo, HarfBuzz, GDK-PixBuf, and fontconfig — installed via
apt-get, never via pip - Report templates: HTML produced by a Jinja2 layer, styled with print CSS — the structural rules from CSS Grid Systems for Report Layouts render identically headless, and the same
@pagesizing from Print-Ready Page Sizing Standards for GIS Reports applies unchanged - Fonts: the exact
.ttf/.otf/.woff2files your reports use, vendored into the repo so the build never depends on a floating font package
# Local sanity check before containerising — should emit a one-page PDF
pip install "weasyprint>=60.0" "jinja2>=3.1"
python -c "from weasyprint import HTML; HTML(string='<h1>ok</h1>').write_pdf('smoke.pdf')"
If that line raises cannot load library 'libgobject-2.0-0', your host is also missing the native stack — which is exactly the failure you are about to prevent inside the container.
Pipeline Architecture
The diagram below shows how a report render flows through a two-stage Docker build: a builder stage compiles Python wheels, a slim runtime stage receives only the installed packages plus the native libraries and fonts, and the CI runner invokes the entrypoint to emit a PDF artifact.
Step-by-Step Implementation
Step 1: Pin the Base Image and Decide Slim vs Full
Start from python:3.12-slim and pin it by digest so a silent upstream rebuild can never change your rendering environment. The full python:3.12 image is roughly 350 MB heavier and ships compilers and headers you do not want present in a runtime container’s attack surface. Everything the full image adds you can install deliberately in a builder stage instead.
# Pin by digest for reproducibility — replace with the digest you resolved.
FROM python:3.12-slim@sha256:<digest> AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
Pinning the digest matters more than pinning the tag: python:3.12-slim is rebuilt weekly with new OS package versions, and a fontconfig or Pango bump between two builds is enough to shift line breaks and change a report’s page count.
Step 2: Install the Native Rendering Libraries
WeasyPrint’s text and vector rendering is done entirely by system libraries. Install Pango (text layout), Cairo (vector drawing), GDK-PixBuf (raster images), HarfBuzz (shaping), and fontconfig. Clean the apt lists in the same RUN layer so they never reach the final image.
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
libharfbuzz0b \
libffi8 \
fontconfig \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends is doing real work here: without it, apt pulls in dozens of suggested packages (documentation, X11 helpers) that bloat the image and add nothing to headless rendering. shared-mime-info is easy to forget and its absence makes WeasyPrint misidentify embedded image types, producing empty image slots rather than an error.
Step 3: Manage Fonts and Rebuild the Font Cache
Do not rely on fonts-dejavu or any distro font package for report output — those versions float and will substitute silently. Copy the exact font files your templates reference into a system font directory, then rebuild the fontconfig cache so the faces resolve on first render instead of triggering a cold-cache rebuild inside the running container.
COPY fonts/ /usr/share/fonts/truetype/report/
RUN fc-cache -f /usr/share/fonts/truetype/report/ \
&& fc-list | sort # log the resolved faces at build time for auditability
Font management is the single largest source of non-reproducibility in headless report rendering. If a report uses CJK or right-to-left labels, the vendored set must include the covering faces — the same embedding discipline described in Typography Mapping for Multi-Language Spatial Data applies, except in a container a missing face fails harder because there is no host fallback to rescue it.
Step 4: Split into Builder and Runtime Stages
Compile wheels in a builder stage that carries gcc and the -dev headers, then copy only the built artifacts into the slim runtime. This keeps compilers out of the shipped image while still letting any package that needs a C extension build cleanly.
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpango1.0-dev libcairo2-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# BuildKit cache mount keeps the pip download cache across builds.
RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --wheel-dir /wheels -r requirements.txt
FROM base AS runtime
COPY --from=builder /wheels /wheels
RUN pip install --no-index --find-links=/wheels weasyprint jinja2 \
&& rm -rf /wheels
The --mount=type=cache directive is the layer-caching win: pip’s download cache persists between builds without ending up baked into an image layer, so a change to one dependency does not re-download the entire set.
Step 5: Drop to a Non-Root User and Verify
Create an unprivileged user, hand it a writable cache directory, and switch to it before the entrypoint. Then bake a render smoke-test into the build so a broken image fails at docker build time rather than in production.
FROM runtime AS final
RUN useradd --create-home --uid 10001 report
ENV HOME=/home/report XDG_CACHE_HOME=/home/report/.cache
WORKDIR /app
COPY --chown=report:report . /app
USER report
# Fail the build if headless rendering is broken.
RUN python -c "from weasyprint import HTML; HTML(string='<p>build-check</p>').write_pdf('/tmp/_check.pdf')"
ENTRYPOINT ["python", "render.py"]
Running as UID 10001 rather than root satisfies Kubernetes runAsNonRoot policies and most CI hardening rules. Pointing XDG_CACHE_HOME at a directory the user owns is essential — otherwise fontconfig cannot write its cache and every render pays a cold-start penalty or, on a read-only root filesystem, errors outright.
Production-Ready Script
The container’s entrypoint below renders one or more report templates to PDF, logs each stage, handles missing fonts and template errors explicitly, and exposes an argparse interface so the same image drives both a single render and a batch. Pair it with the orchestration patterns in Scheduled Batch Report Generation when you move from one report to many.
#!/usr/bin/env python3
"""
render.py — headless WeasyPrint entrypoint for a Dockerised report pipeline.
Usage:
python render.py --template spatial_report.html --context ctx.json --out /out/report.pdf
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, TemplateError, select_autoescape
from weasyprint import HTML, CSS
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("render")
TEMPLATE_DIR = Path(os.environ.get("TEMPLATE_DIR", "/app/templates"))
STYLESHEET = Path(os.environ.get("STYLESHEET", "/app/static/report.css"))
def load_context(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.error("Cannot read context %s: %s", path, exc)
raise SystemExit(2) from exc
def render_pdf(template_name: str, context: dict, out_path: Path) -> Path:
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
try:
html_string = env.get_template(template_name).render(**context)
except TemplateError as exc:
logger.error("Template render failed: %s", exc)
raise SystemExit(3) from exc
out_path.parent.mkdir(parents=True, exist_ok=True)
stylesheets = [CSS(filename=str(STYLESHEET))] if STYLESHEET.exists() else []
# base_url anchors relative asset paths at the template directory,
# not the container's working directory.
HTML(string=html_string, base_url=str(TEMPLATE_DIR)).write_pdf(
str(out_path), stylesheets=stylesheets
)
logger.info("Rendered %s (%d bytes)", out_path, out_path.stat().st_size)
return out_path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Headless report PDF renderer")
parser.add_argument("--template", required=True)
parser.add_argument("--context", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args(argv)
context = load_context(args.context)
render_pdf(args.template, context, args.out)
return 0
if __name__ == "__main__":
sys.exit(main())
Edge Cases & Advanced Configuration
Deterministic Output and Missing Timestamps
By default WeasyPrint stamps each PDF with the current time, so two identical inputs produce non-identical bytes and break any content-hash comparison. Set SOURCE_DATE_EPOCH in the image and WeasyPrint uses it as the document creation date, making output reproducible — a prerequisite for the content-addressed naming described in Report Artifact Storage & Versioning.
ENV SOURCE_DATE_EPOCH=1704067200
Slim Image Size Under Pressure
If the image must be as small as possible, install libraries into the runtime stage, then strip apt caches and documentation in the same layer. The deeper minimisation techniques — pruning unused fontconfig configs, choosing which Pango sub-libraries to keep — are covered in Installing WeasyPrint Dependencies in Slim Docker Images.
Rendering Live Maps That Need a Display Server
WeasyPrint itself is fully headless and needs no display. But if your pipeline also rasterises maps with QGIS or Mapnik before embedding them, those tools open an X11 context and will crash in a container with no display. Wrap them in a virtual framebuffer — the full pattern is in Running QGIS and Mapnik Headless with Xvfb. Keep map rasterisation in a separate stage or sidecar so the lightweight PDF renderer stays free of the heavy geospatial stack.
Read-Only Root Filesystems
Hardened runtimes mount the root filesystem read-only. Fontconfig, WeasyPrint’s temp files, and Matplotlib caches all need somewhere to write. Mount a writable tmpfs at /home/report/.cache and /tmp, and confirm XDG_CACHE_HOME points inside it, or the first render fails with a permission error rather than a cache miss.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
OSError: cannot load library 'libgobject-2.0-0' on import |
Native Pango/GLib stack missing from the base image | Install libpango-1.0-0, libpangocairo-1.0-0, libcairo2, libgdk-pixbuf-2.0-0 via apt-get |
| Text renders as blank rectangles or boxes | Font faces not present; fontconfig cache stale | Vendor the exact fonts into /usr/share/fonts and run fc-cache -f in the build |
| Page count differs between local and container builds | Distro font package floated to a different version | Pin the base image by digest and bundle exact font files instead of installing font packages |
| First render in a running container is slow, then fast | Cold fontconfig cache built at runtime | Run fc-cache -f at build time; point XDG_CACHE_HOME at a writable owned directory |
| Container exits with permission error writing cache | Runs as root or HOME unwritable under non-root user |
Create a non-root user, set HOME/XDG_CACHE_HOME, and chown the cache directory |
| Embedded images appear as empty boxes | shared-mime-info absent, so MIME sniffing fails |
Add shared-mime-info to the apt install; verify base_url resolves asset paths |
Frequently Asked Questions
Why does WeasyPrint produce blank text or fail to import inside my Docker image?
WeasyPrint has no bundled text engine — it links against the system Pango, Cairo, and HarfBuzz shared libraries at runtime. A python:slim base image ships without them, so text glyphs render blank or the import raises an OSError about libgobject. Install libpango-1.0-0, libpangocairo-1.0-0, libcairo2, and libgdk-pixbuf-2.0-0 with apt-get, then rebuild the fontconfig cache with fc-cache -f. The failure is silent on text specifically because Cairo can still draw boxes without a working Pango text path.
Should I use a slim or full Python base image for PDF rendering?
Start from python:3.12-slim and add only the native libraries WeasyPrint needs. The full python:3.12 image is roughly 350 MB larger and ships build toolchains you do not want in a runtime container. A multi-stage build gives you the best of both: compile wheels in a builder stage that has gcc, then copy only the installed packages into a slim runtime stage.
How do I make PDF output byte-for-byte reproducible across container rebuilds?
Pin the base image by digest, pin every apt and pip version, bundle the exact font files into the image rather than installing font packages that float, and set SOURCE_DATE_EPOCH so WeasyPrint writes a deterministic PDF creation timestamp. Fonts are the most common source of drift: a fontconfig fallback substituting a different face silently changes line breaks and page counts.
Do I need a non-root user for a headless rendering container?
Yes. WeasyPrint and fontconfig never require root at runtime, so create an unprivileged user and switch to it with USER before the entrypoint. This limits the blast radius if a malformed report input triggers a library vulnerability, and most Kubernetes and CI security policies reject images that run as UID 0. Point HOME and XDG_CACHE_HOME at a writable directory the user owns so fontconfig can build its cache.
Detailed Guides in This Section
- Installing WeasyPrint Dependencies in Slim Docker Images — the minimal apt package set, layer-caching tactics, and how far you can shrink the runtime image before rendering breaks.
- Running QGIS and Mapnik Headless with Xvfb — wrap display-dependent map rasterisers in a virtual framebuffer so they run cleanly alongside a headless PDF renderer.
Related
- Scheduled Batch Report Generation — drive this image from cron, GitHub Actions, or a task queue to render many reports on a schedule
- Report Artifact Storage & Versioning — store the deterministic PDFs this container produces with content-hash naming
- CSS Grid Systems for Report Layouts — the print-CSS structure that renders identically in a headless container
- Print-Ready Page Sizing Standards for GIS Reports —
@pagesizing that carries over unchanged into headless rendering - Jinja2 Templating & Theme Logic for Automated Spatial Reporting — the templating layer that produces the HTML this container turns into PDF
Parent: CI/CD Scheduling & Automation for Spatial Report Pipelines
Conclusion
A headless report container is only as trustworthy as it is reproducible: pin the base image, install the Pango and Cairo stack deliberately, vendor exact fonts, and drop to a non-root user, and the same bytes render locally and in CI every time. With a multi-stage build keeping the runtime slim and SOURCE_DATE_EPOCH making output deterministic, this image becomes the reliable foundation for every scheduled batch and versioned artifact your CI/CD Scheduling & Automation for Spatial Report Pipelines workflow depends on.