Headless Chromium in Docker: Alpine vs Debian base image and why fonts go missing on Alpine

Last quarter I spent two days debugging why our PDF-from-HTML pipeline was rendering text as little tofu boxes (□ □ □) on every page. The HTML was fine, the chromium-headless invocation was fine, the output PDF was fine in every way except the text. The fix was a one-line change to the Dockerfile: switch from chromium:alpine to a Debian base. The Alpine image was missing fonts.

This is one of the more annoying gotchas of running headless Chromium in containers. Alpine is the obvious choice — smallest base, fastest pulls, smallest attack surface — and it’s where this problem mostly lives.

Why Alpine + Chromium has a fonts problem

Chromium delegates font rendering to FreeType, which delegates font discovery to Fontconfig, which scans known directories for font files. The standard Debian/Ubuntu chromium package depends on a half-dozen fonts-* packages by default — DejaVu, Liberation, Noto, etc. The Alpine chromium package depends on none of them. The base Alpine image has zero fonts installed.

Result: Chromium runs, parses HTML correctly, but when it asks Fontconfig for “what should I render this character with?” the answer is “nothing.” Fallback is the empty box.

The minimal Alpine fix

FROM alpine:3.19

RUN apk add --no-cache \
    chromium \
    nss \
    freetype \
    harfbuzz \
    ca-certificates \
    ttf-freefont \
    font-noto-emoji \
    font-noto \
    font-noto-cjk \
    fontconfig

ENV CHROME_BIN=/usr/bin/chromium-browser

Walking through the additions:

  • nss — Network Security Services. Required for HTTPS to work; without it Chromium can launch but fails on every TLS connection.
  • freetype, harfbuzz, fontconfig — the font rendering pipeline.
  • ttf-freefont — basic Latin/Cyrillic/Greek coverage (FreeSerif, FreeSans, FreeMono).
  • font-noto + font-noto-cjk + font-noto-emoji — Google Noto fonts. CJK is critical if you have any Chinese/Japanese/Korean content; emoji because emoji-with-FreeFont is hopeless.

That image lands at about 320 MB compressed. Bigger than bare-Alpine but still much smaller than Debian.

The Debian alternative

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    chromium \
    fonts-noto \
    fonts-noto-cjk \
    fonts-noto-color-emoji \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

ENV CHROME_BIN=/usr/bin/chromium

Debian’s chromium package pulls fonts-freefont-ttf as a recommended dependency, so basic ASCII works out of the box. But the default font stack is sparse; you still want fonts-noto + fonts-noto-cjk + fonts-noto-color-emoji unless you’re 100% sure your content is Latin-only with no emoji.

Final image lands at ~580 MB compressed. Bigger but with fewer surprises.

The actual quirks per base

  • Alpine: the binary is chromium-browser, not chromium. The --no-sandbox flag is usually required because Alpine doesn’t ship the kernel features Chromium’s sandbox expects. Older versions had a memory-mapping bug that crashed under specific PDF rendering paths — fully fixed by Alpine 3.19+, but if you’re stuck on 3.16 you may still see it.
  • Debian: the binary is chromium. Sandbox works out of the box (kernel user namespaces are enabled). --no-sandbox is a code smell, not a requirement.

Verifying fonts are actually installed

# From inside the container
docker exec -it mycontainer fc-list | wc -l
# Should be >= 100; if it's 0 or single-digit you're missing fonts.

# Render-test a known string with all major scripts
docker exec -it mycontainer chromium-browser --headless --disable-gpu \
    --screenshot=/tmp/test.png 'data:text/html,
<html><body style="font-size:32px">
Latin: ABCDEFG
Cyrillic: АБВГД
Chinese: 你好世界
Japanese: こんにちは
Korean: 안녕하세요
Emoji: 😀 🎉 🚀
</body></html>'

If the resulting test.png shows tofu boxes for any line, that script’s font is missing. Add the corresponding Noto subpackage and rebuild.

The “puppeteer says no” debug recipe

If your container hosts puppeteer or playwright and the test runs are flaky:

  • Crashes mid-launch: usually missing nss or nspr. Symptom: “Failed to load resource: net::ERR_CONNECTION_FAILED” on every HTTPS URL.
  • Renders blank pages: usually --no-sandbox missing in Alpine, or --disable-gpu needed. Symptom: white screenshots, identical content regardless of URL.
  • Renders text as boxes: fonts. Per above.
  • Renders forever, never completes: usually a --single-process mode mismatch with how puppeteer is invoking it. Try --no-zygote --single-process.

What I actually use

For a production PDF-rendering service: Debian-bookworm-slim. The 250 MB extra is worth not debugging Alpine’s sandbox + font quirks. For a dev sandbox or CI test runner where image size matters more than reliability: Alpine 3.19+ with the font set above. Both work; the right answer depends on whether you’re optimising for ops simplicity or pull bandwidth.

If you’ve ever stared at “□ □ □” on a generated PDF and questioned your career choices — fc-list inside the container is your friend. Ninety percent of these bugs are missing fonts; ten percent are sandbox issues; the remaining edge cases will haunt you for hours regardless of base image.

Cover photo: Brett Jordan on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.