Installing WeasyPrint Dependencies in Slim Docker Images

weasyprint is pure Python, but the moment it lays out a page it reaches into a stack of native libraries — Pango for text shaping, Cairo for the drawing surface, GDK-PixBuf for raster images, and libffi for the ctypes bridge that binds them. A pip install weasyprint inside a bare python:3.12-slim image succeeds and then throws OSError: cannot load library 'libpango-1.0.so.0' the first time it renders. This guide, part of the Headless PDF Rendering in Docker Containers workflow, pins down the exact apt packages, weighs Debian slim against Alpine, and shows a multi-stage build that stays small while rendering correctly on the first try.

WeasyPrint native dependency stack Diagram showing the weasyprint Python package resting on four native libraries installed by apt: libpango, libcairo, libgdk-pixbuf and libffi, plus shared-mime-info and fonts, producing a rendered PDF. weasyprint (pip) pure-Python layout engine libpango-1.0-0 text shaping libcairo2 drawing surface libgdk-pixbuf-2.0-0 raster images libffi ctypes bridge rendered PDF + shared-mime-info + fonts

Prerequisites

  • Docker 24+ and a registry to push to (or a local build for CI)
  • Python 3.10+ target; examples use python:3.12-slim (Debian Bookworm base)
  • weasyprint>=61 (the 60+ series links against Pango via libgobject; older pins need libpangocairo explicitly)
  • A print stylesheet ready to render — see Configuring WeasyPrint @page Rules for Spatial Reports for the CSS side

Step 1: Install the Exact apt Runtime Libraries

On a Debian slim base the missing pieces are all runtime shared objects. Install them in a single apt-get layer, then delete the apt lists to reclaim space. Do not install -dev packages in the runtime image — weasyprint loads the versioned .so files at runtime and never compiles against headers.

DOCKERFILE
FROM python:3.12-slim AS runtime

RUN apt-get update && apt-get install --no-install-recommends -y \
        libpango-1.0-0 \
        libpangocairo-1.0-0 \
        libcairo2 \
        libgdk-pixbuf-2.0-0 \
        libffi8 \
        shared-mime-info \
    && rm -rf /var/lib/apt/lists/*

Each entry maps to a concrete failure if omitted: dropping libpango-1.0-0 breaks all text; libcairo2 breaks the surface; libgdk-pixbuf-2.0-0 breaks embedded PNG/JPEG map exports; shared-mime-info makes weasyprint misidentify image MIME types and silently skip them. libffi8 is the Bookworm SONAME — on older Bullseye bases it is libffi7.

Step 2: Decide Between Debian slim and Alpine

Alpine images are tempting because they start near 5 MB, but Alpine uses musl libc instead of glibc. Two consequences matter for reporting pipelines: PyPI ships manylinux (glibc) wheels, so many transitive dependencies must compile from source on Alpine, inflating build time; and font hinting under musl’s freetype build differs subtly, which can shift text metrics and break the exact-measure layouts described in How to Set Exact Bleed Margins in WeasyPrint for GIS Maps.

The Alpine equivalent of Step 1 uses apk and different package names:

DOCKERFILE
FROM python:3.12-alpine
RUN apk add --no-cache \
        pango \
        cairo \
        gdk-pixbuf \
        libffi \
        font-dejavu \
    && pip install --no-cache-dir weasyprint

Alpine’s pango package pulls Cairo transitively, so the list is shorter, but you trade that for musl’s rendering quirks. For deterministic, print-ready spatial PDFs, prefer python:3.12-slim; reach for Alpine only when image size is a hard constraint and you have snapshot tests guarding the layout.

Step 3: Install Fonts for Deterministic Output

A slim base ships no fonts. Without them Pango falls back to a default that may not exist, and glyphs render as tofu boxes. Install exactly the families your templates declare in font-family, and no more — every font package is dead weight in the image.

DOCKERFILE
RUN apt-get update && apt-get install --no-install-recommends -y \
        fonts-dejavu-core \
        fonts-liberation2 \
        fontconfig \
    && fc-cache -f \
    && rm -rf /var/lib/apt/lists/*

fontconfig plus fc-cache -f rebuilds the font index so Pango resolves families at render time. If your reports carry multilingual map labels, add the CJK or complex-script packages your content needs rather than pulling the entire fonts-noto meta-package, which adds hundreds of megabytes.

Step 4: Split the Build Into Stages

Keep the compiler toolchain and pip’s build artifacts out of the shipped image. A builder stage installs Python packages into a virtual environment; the runtime stage copies only that venv plus the native runtime libraries from Step 1.

DOCKERFILE
# ---- builder ----
FROM python:3.12-slim AS builder
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt   # weasyprint, jinja2, geopandas

# ---- runtime ----
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install --no-install-recommends -y \
        libpango-1.0-0 libpangocairo-1.0-0 libcairo2 \
        libgdk-pixbuf-2.0-0 libffi8 shared-mime-info \
        fonts-dejavu-core fontconfig \
    && fc-cache -f && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .

The runtime stage never sees gcc, pip caches, or .pyc build detritus. This is the same isolation principle that keeps a nightly job image lean when Scheduling Nightly PDF Runs with Cron and GitHub Actions pulls the image on every run.

Step 5: Run a Smoke Render at Build Time

Catch a missing .so at build time, not at 02:00 in a scheduled job. Add a build-time render that fails the image if any native library is absent.

DOCKERFILE
RUN python -c "import weasyprint; \
weasyprint.HTML(string='<h1>smoke</h1>').write_pdf('/tmp/smoke.pdf'); \
import os, sys; sys.exit(0 if os.path.getsize('/tmp/smoke.pdf') > 800 else 1)"

Because the render happens in a RUN layer, a Pango or Cairo loading error aborts docker build with a non-zero exit. The > 800 byte check guards against a truncated PDF that “succeeds” but contains no page content.

Key Parameters / Configuration Reference

Package (Debian) Provides Failure if omitted
libpango-1.0-0 Text layout & shaping cannot load library 'libpango-1.0.so.0'
libpangocairo-1.0-0 Pango→Cairo bridge Text renders but no glyphs on the surface
libcairo2 2D drawing surface No PDF surface; render aborts
libgdk-pixbuf-2.0-0 Raster image decoding Embedded map PNG/JPEG dropped silently
libffi8 ctypes FFI to native libs Import-time OSError on libffi.so
shared-mime-info MIME sniffing for assets Images skipped as unknown type
fonts-dejavu-core + fontconfig Glyph coverage & index Tofu boxes for all text

Common Pitfalls

  • Installing -dev packages in the runtime image. libpango1.0-dev pulls headers and a compiler toolchain you never use at runtime, adding ~150 MB. Install the versioned runtime package (libpango-1.0-0) instead.
  • Wrong libffi SONAME for the base. Bookworm ships libffi8, Bullseye ships libffi7. Pinning the wrong one silently installs nothing useful and imports fail. Match the SONAME to your base image tag.
  • Forgetting fc-cache -f after adding fonts. Copying font files without rebuilding the fontconfig cache leaves Pango unable to resolve the family, so it falls back to tofu even though the font is present.
  • Assuming Alpine is a drop-in. musl freetype hinting shifts text metrics; a layout tuned on slim can overflow a fixed frame on Alpine. Re-run snapshot tests after any base-image switch.

Verification

Confirm the runtime image renders and that no dev cruft survived, then check the final size:

Bash
docker build -t report-renderer:slim .
docker run --rm report-renderer:slim \
    python -c "import weasyprint; weasyprint.HTML(string='<p>ok</p>').write_pdf('/tmp/v.pdf'); \
print('rendered', __import__('os').path.getsize('/tmp/v.pdf'), 'bytes')"
docker run --rm report-renderer:slim sh -c "command -v gcc || echo 'no compiler (good)'"
docker images report-renderer:slim --format '{{.Size}}'

A correctly built image renders a non-trivial PDF, reports no compiler present, and lands around 250–350 MB — an order of magnitude smaller than the full python:3.12 base with build tools.