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.
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 vialibgobject; older pins needlibpangocairoexplicitly)- 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.
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:
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.
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.
# ---- 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.
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
-devpackages in the runtime image.libpango1.0-devpulls headers and a compiler toolchain you never use at runtime, adding ~150 MB. Install the versioned runtime package (libpango-1.0-0) instead. - Wrong
libffiSONAME for the base. Bookworm shipslibffi8, Bullseye shipslibffi7. Pinning the wrong one silently installs nothing useful and imports fail. Match the SONAME to your base image tag. - Forgetting
fc-cache -fafter 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
slimcan 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:
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.
Related
- Headless PDF Rendering in Docker Containers — parent section covering the full containerized rendering workflow
- Running QGIS and Mapnik Headless with Xvfb — the display-server counterpart when your images come from a GIS renderer
- Configuring WeasyPrint @page Rules for Spatial Reports — the CSS that runs inside the container you just built
- Scheduling Nightly PDF Runs with Cron and GitHub Actions — pulling this image on a schedule