Pinning Fonts and GPU Backend in a Playwright Docker Image
Two runners, one commit, two different baselines: the label halos are a subpixel heavier on the CI node than on your laptop, and a WebGL gradient dithers one way in a container and another on the host GPU. Both frames are “correct” — they are just rendered by different fonts and a different rasterizer. The fix is not a looser diff threshold; it is an image where the fonts, locale, timezone, and GL backend are frozen into the layers themselves, so every run inherits an identical rendering environment. This guide gives a concrete, digest-pinned Dockerfile that removes each source of nondeterminism in turn, then verifies the result by diffing two captures taken on two different hosts.
This is one task inside Containerized Rendering Environments for Map Tests, the guide on making the runner itself reproducible. It sits under the broader discipline described in Web Map Visual Testing Fundamentals & Toolchains, which frames environmental determinism — fonts, locale, timezone, GPU backend — as a load-bearing requirement rather than an afterthought.
Prerequisites
Step-by-step procedure
1. Pin the base image to an immutable digest
A tag like :v1.48.0-jammy is repointed by upstream whenever the patch layer is rebuilt, which silently changes the bundled Chromium, system libraries, and font cache under you. Resolve the tag to a content-addressed digest once, then reference that digest so every docker build on every machine starts from byte-identical bytes.
# Resolve the mutable tag to an immutable digest, then record it.
docker pull mcr.microsoft.com/playwright:v1.48.0-jammy
docker inspect --format='{{index .RepoDigests 0}}' \
mcr.microsoft.com/playwright:v1.48.0-jammy
# => mcr.microsoft.com/playwright@sha256:9b2f...c1e4
# Dockerfile — start from the digest, never the floating tag.
FROM mcr.microsoft.com/playwright@sha256:9b2f0c...c1e4
Pinning the digest is the same discipline applied to test data by Baseline Management for Tile Servers: the environment that produced a baseline must be reconstructable exactly, or the baseline is not trustworthy.
2. Install exact font packages and rebuild the font cache
A missing glyph is substituted by whatever fallback face fontconfig can find, and a fallback face shifts every affected label by a subpixel — enough to light up a full-frame diff. Install the specific Noto packages your style needs, pin their versions, and rebuild the cache in the same layer so glyph selection is deterministic. Do not install a metapackage like fonts-noto that pulls in hundreds of faces, because any of them can win a fallback contest and change the frame.
# Install ONLY the faces the map style references, at pinned versions.
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-core=20201225-1build1 \
fonts-noto-cjk=1:20220127+repack1-1 \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f -v
After the build, confirm the cache resolves your style’s font stack to the exact faces you installed — fc-match "Noto Sans" should return a Noto face, not an unexpected substitute.
3. Freeze locale, timezone, and language
Locale controls number and label formatting; timezone leaks into any time-of-day styling or attribution stamp; language changes which script fontconfig prefers when a glyph exists in several faces. Bake all three into the image environment so no test and no runner can drift.
# Generate a single UTF-8 locale and freeze the environment.
RUN apt-get update && apt-get install -y --no-install-recommends locales \
&& sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
ENV LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
TZ=UTC
4. Force the software GL backend via Chromium flags
Host GPUs rasterize WebGL differently — anti-aliasing, gradient dithering, and line-cap coverage all vary by driver. Forcing Chromium onto the ANGLE/SwiftShader software backend replaces the host GPU with a deterministic CPU rasterizer, so the same triangles produce the same pixels on every node. Set the flags at browser launch rather than baking them into the image, so they travel with the test config.
// playwright.config.js — launch Chromium on the software GL path.
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
launchOptions: {
args: [
'--headless=new',
'--use-gl=angle',
'--use-angle=swiftshader', // CPU rasterizer, no host GPU
'--disable-gpu',
'--force-color-profile=srgb',
'--force-device-scale-factor=1',
],
},
},
});
The --use-gl=angle --use-angle=swiftshader pair is the portable form of the software backend; older --use-gl=swiftshader still works but routes through a different path on newer Chromium. This is the same GL determinism that handling async tile loading relies on once the frame is captured.
5. Disable font hinting so glyph coverage is host-independent
Even with the right font installed, the hinter nudges glyph outlines to the pixel grid, and the FreeType build behind that hinting can differ between images. Turn hinting off and pin anti-aliasing on through a fontconfig drop-in, and disable Chromium’s own hinting at launch, so text coverage is computed the same way everywhere.
# /etc/fonts/local.conf — no hinting, deterministic anti-aliasing.
RUN printf '%s\n' \
'<?xml version="1.0"?>' \
'<!DOCTYPE fontconfig SYSTEM "fonts.dtd">' \
'<fontconfig>' \
' <match target="font">' \
' <edit name="hinting" mode="assign"><bool>false</bool></edit>' \
' <edit name="hintstyle" mode="assign"><const>hintnone</const></edit>' \
' <edit name="antialias" mode="assign"><bool>true</bool></edit>' \
' <edit name="autohint" mode="assign"><bool>false</bool></edit>' \
' </match>' \
'</fontconfig>' > /etc/fonts/local.conf \
&& fc-cache -f
Add --font-render-hinting=none to the Chromium args from step 4 so the browser honors the same policy. With hinting off, a label’s coverage no longer depends on which host built the FreeType library.
6. Verify by diffing two captures across two hosts
The only proof that the environment is deterministic is a byte-for-byte match of the same capture taken on two different machines. Build the pinned image, run the identical capture on both hosts, and compare the two PNGs — not against a baseline, but against each other.
# On host A (laptop) and host B (CI runner), from the same image digest:
docker run --rm -v "$PWD/out:/out" map-visual:pinned \
npx playwright test tests/home-z12.spec.js
# Copy host-A/home-z12.png and host-B/home-z12.png to one place, then:
cmp host-a/home-z12.png host-b/home-z12.png \
&& echo "IDENTICAL — environment is deterministic" \
|| echo "DIVERGENT — a nondeterminism source remains"
An identical result means the image has frozen every environmental input; a divergence points at whichever layer you have not yet pinned. Feed the same captures into the CI gate described in GitHub Actions & GitLab CI Gates for Map Visual Tests so the deterministic image is the one that generates and checks every baseline.
Verification
Confirm each nondeterminism source is actually removed before trusting the image in CI:
A green cross-host cmp under different GPUs is the single strongest signal; if it passes on identical hardware but fails across two GPUs, the GL backend is not actually forced onto SwiftShader.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| CJK labels render as tofu boxes or shift between hosts | fonts-noto-cjk missing, or a metapackage pulled an extra face that wins fallback |
Install exactly fonts-noto-core and fonts-noto-cjk at pinned versions and rerun fc-cache -f, per step 2 |
| WebGL frame differs across two GPUs despite the flags | Chromium still on the host GL path — --use-gl set without the ANGLE pair, or flags dropped by a wrapper |
Pass --use-gl=angle --use-angle=swiftshader --disable-gpu together and confirm SwiftShader via WEBGL_debug_renderer_info |
| Same Dockerfile builds a different image next week | Base referenced by a floating :tag that upstream repointed |
Reference the immutable @sha256: digest from step 1 and rebuild from it |
Frequently asked questions
Why pin the base image by @sha256 digest instead of a version tag?
A tag like v1.48.0-jammy is mutable — upstream rebuilds the patch layer and repoints the tag, which silently swaps the bundled Chromium, system libraries, and font cache. That is enough to change label rasterization between two builds of the “same” Dockerfile. Resolving the tag to a content-addressed @sha256: digest once and referencing that digest guarantees every build on every machine starts from byte-identical bytes.
Do I need SwiftShader if my CI runners all have the same GPU?
Only if you can guarantee that identical GPU, driver version, and OpenGL stack forever — and that developers reproducing a failure locally have it too. In practice laptops and CI nodes differ, and driver updates change rasterization. Forcing the ANGLE/SwiftShader software backend removes the host GPU from the equation entirely, so WebGL output is a deterministic function of the scene rather than of the hardware, and local and CI frames match.
Why install specific Noto packages rather than the full fonts-noto metapackage?
The metapackage installs hundreds of faces, any of which can win a fontconfig fallback contest for a glyph your style did not intend, shifting labels between images built at different times. Installing only fonts-noto-core and fonts-noto-cjk at pinned versions keeps glyph selection deterministic and the image small, and lets fc-match confirm exactly which face renders each script.
Should the GL and hinting flags go in the Dockerfile or the Playwright config?
Put the font packages, locale, timezone, and fontconfig drop-in in the Dockerfile, because they define the environment. Put the Chromium launch flags — the ANGLE/SwiftShader backend and --font-render-hinting=none — in the Playwright launch options, so they travel with the test config and stay visible to anyone reading the suite. The image freezes what fonts exist; the launch flags freeze how they are rasterized.
Related
- Up to the parent guide Containerized Rendering Environments for Map Tests, and the broader Web Map Visual Testing Fundamentals & Toolchains.
- Baseline Management for Tile Servers — keying deterministic captures to the environment that produced them.
- GitHub Actions & GitLab CI Gates for Map Visual Tests — running the pinned image in the pipeline that gates a deploy.
- Chromium vs WebKit vs Firefox MapLibre rendering divergence — why per-engine baselines are still needed even after the environment is frozen.
← Back to Containerized Rendering Environments for Map Tests