Containerized Rendering Environments for Map Tests

A web-map screenshot is a function of far more than your application code. The exact pixels a headless browser paints depend on which font files resolve a text-field, which locale formats a scale bar, which timezone stamps a live-data label, and which OpenGL implementation rasterizes the WebGL canvas. Change any one of those inputs — a font package that ships in Ubuntu 22.04 but not 24.04, a runner defaulting to America/Chicago instead of UTC, a host GPU standing in for software rendering — and every baseline you captured elsewhere diffs against every capture here. The container image is where you nail all of those inputs down at once, as a single versioned artifact. This page shows how to build that image so a MapLibre GL, Mapbox GL, Leaflet, or OpenLayers frame renders to the same bytes on a laptop, a colleague’s machine, and a CI node.

This page extends the environmental-determinism layer of Web Map Visual Testing Fundamentals & Toolchains, the parent guide that frames rendering control, versioned baselines, diff tuning, and CI gating as one discipline. Where that guide names environmental determinism as the fourth pillar of a stable capture, this one builds it: a concrete, digest-pinned image that carries fonts, locale, timezone, GL stack, and browser as one unit.

What “environmental determinism” means for a container

Determinism in visual testing is the property that the same commit produces the same frame. Most teams reach for it at the test layer — waiting for the map to settle, intercepting tiles — and those controls are necessary. But they sit on top of an environment that the test cannot see or assert. A test cannot detect that the runner substituted DejaVu Sans for Noto Sans; it just captures the substituted glyphs and diffs them into a failure. Environmental determinism is the guarantee that the layer beneath the test — the operating system, its libraries, and the browser build — is byte-for-byte the same everywhere the suite runs.

A container image is the right unit for that guarantee because it packages every environmental input together and pins them with one identifier. The alternatives leak. A README that says “install fonts-noto-core” relies on every engineer reading it and every runner’s package mirror resolving the same version. A CI-only apt-get install step fixes the runner but not the laptop where baselines were blessed. A container image, by contrast, is a filesystem snapshot: the fonts are in it, the locale is generated in it, the browser binary is a specific build in it. Pull the image by digest and you get exactly those bytes, whether you are on macOS with an Apple GPU, Windows under WSL2, or an ubuntu-24.04 GitHub Actions runner.

The environmental axes that actually move map pixels are a short, well-understood list:

  • Fonts. Glyph outlines and, critically, glyph availability. A missing face triggers a fallback that shifts every label. CJK, Arabic, and Devanagari coverage matters the moment your basemap has non-Latin place names.
  • Locale. Number, date, and coordinate formatting; the digits and separators a scale bar or coordinate readout prints.
  • Timezone. Any label bound to Date — a live-traffic timestamp, a “last updated” caption — renders differently at a timezone boundary.
  • GL backend. The single largest source of “passes locally, fails in CI.” A discrete GPU and a software rasterizer dither gradients, anti-alias edges, and compile shaders differently.
  • Browser build. Skia and the font-hinting pipeline change between Chromium versions; a minor bump can move sub-pixel label placement across the whole frame.

Pin all five in the image and the test layer is finally comparing like with like.

Design: layering determinism into one image

The clean way to build the image is as an explicit stack of layers, each pinning one axis, built on top of a base whose identity is fixed by digest. The Playwright base image is the natural foundation: it already carries a specific Chromium, WebKit, and Firefox build plus their system dependencies, versioned to the Playwright release. You add the map-specific determinism on top — fonts, locale, timezone, and the GL configuration — then your application and test code.

Layers of determinism inside a map-test container image A stack of six layers built bottom to top. The foundation is the base image pinned by digest — the Playwright image at a fixed sha256 carrying a specific Chromium, WebKit and Firefox build. On top of it sits the fonts layer, installing fonts-noto-core and fonts-noto-cjk for stable Latin and CJK glyph coverage. Above that the locale and timezone layer generates en_US.UTF-8 and sets TZ to UTC. Above that the GL backend layer forces software rendering with use-gl set to angle and use-angle set to swiftshader. Above that the browser is fixed by the base Playwright version. The top layer is the application and test code. A caption notes that one image digest pins all five environmental axes together. One image digest pins every environmental input Application + test code map bundle, fixtures, capture + diff scripts Browser build Chromium fixed by the Playwright base version GL backend — software --use-gl=angle --use-angle=swiftshader Locale + timezone LANG=en_US.UTF-8 · TZ=UTC Fonts fonts-noto-core · fonts-noto-cjk Base image — pinned by digest mcr.microsoft.com/playwright@sha256:… Build bottom-up; pull by digest; every axis travels together.

Building the axes as discrete layers is not just cosmetic. Docker caches per layer, so pinning fonts in their own RUN keeps the expensive apt-get step out of the rebuild path when only test code changes — the caching discussion later leans directly on this ordering. It also makes the image auditable: a reviewer can read the Dockerfile top to bottom and see exactly which determinism controls are present, which is far harder when everything is crammed into one shell step.

Step-by-step: building the deterministic image

The procedure below produces an image you can run identically on a developer machine and in CI. It targets a MapLibre GL / Mapbox GL stack; the same layers apply to Leaflet and OpenLayers, which lean even harder on system fonts for their DOM-rendered labels.

1. Pin the base image by digest

A tag like mcr.microsoft.com/playwright:v1.44.0-jammy is mutable — the registry can repoint it to a rebuilt image with a patched font library or a newer Skia. For a baseline that must survive for months, reference the base by its immutable sha256 digest instead. Resolve the digest once:

docker pull mcr.microsoft.com/playwright:v1.44.0-jammy
docker inspect --format='{{index .RepoDigests 0}}' \
  mcr.microsoft.com/playwright:v1.44.0-jammy
# mcr.microsoft.com/playwright@sha256:9c1f...e4a2

Then start the Dockerfile from that digest. The comment records the human-readable tag it corresponds to so the pin is auditable:

# Playwright v1.44.0-jammy — Chromium 125, WebKit, Firefox pinned by the base
FROM mcr.microsoft.com/playwright@sha256:9c1f...e4a2

Now the browser build — the axis you cannot easily install yourself — is fixed. A Playwright upgrade becomes a deliberate digest change with a baseline re-bless, not a silent drift.

2. Install a fixed font stack

Install exactly the font packages your basemap needs, in their own layer, and rebuild the font cache. fonts-noto-core covers Latin, Greek, and Cyrillic; fonts-noto-cjk covers Chinese, Japanese, and Korean. Add fonts-noto-color-emoji only if your labels use emoji, since color-emoji rendering is a common cross-engine divergence.

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      fonts-noto-core \
      fonts-noto-cjk \
      locales \
      tzdata \
 && fc-cache -f \
 && rm -rf /var/lib/apt/lists/*

The fc-cache -f matters: without it, fontconfig may resolve a face from a stale cache on first run and produce a one-off frame that never reproduces. Pinning versions further — fonts-noto-core=20201225-1build1 — is worth it when your registry mirror is not itself frozen, though the digest-pinned base already fixes the apt suite the packages resolve from.

3. Freeze locale and timezone

Generate a single UTF-8 locale and export it, then set the timezone to UTC. This removes two silent diff sources: a locale that changes digit shaping or decimal separators in a coordinate readout, and a timezone that shifts a Date-derived label across midnight.

RUN sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen \
 && locale-gen en_US.UTF-8
ENV LANG=en_US.UTF-8 \
    LANGUAGE=en_US:en \
    LC_ALL=en_US.UTF-8 \
    TZ=UTC
RUN ln -snf /usr/share/zoneinfo/UTC /etc/localtime \
 && echo "UTC" > /etc/timezone

Set the same values at the browser-context layer too — Playwright’s locale and timezoneId — so a live-data caption cannot pick up a different formatting path inside the page than the OS provides. Belt and braces: the OS pin covers native rendering, the context pin covers JavaScript Intl and Date.

4. Force a software GL backend

The host GPU is the axis you most want out of the picture, because it is the one you least control across laptops and CI. Force Chromium to rasterize WebGL through ANGLE’s SwiftShader software path so the output is a deterministic function of the software stack, not the hardware.

import { chromium } from "playwright";

const browser = await chromium.launch({
  args: [
    "--headless=new",
    "--use-gl=angle",
    "--use-angle=swiftshader",
    "--disable-gpu",              // no hardware acceleration
    "--force-color-profile=srgb", // fixed color management
    "--font-render-hinting=none", // stable glyph hinting
    "--hide-scrollbars",
  ],
});

const context = await browser.newContext({
  viewport: { width: 1024, height: 768 },
  deviceScaleFactor: 1,
  locale: "en-US",
  timezoneId: "UTC",
  colorScheme: "light",
});

SwiftShader is slower than a GPU but produces identical pixels regardless of host, which is exactly the trade a baseline wants. The deeper reasoning about which flags map to which pixels — and where the GL stack interacts with font hinting — is the subject of the dedicated task guide on pinning fonts and the GPU backend in a Playwright Docker image.

5. Match the container to the baseline-generation environment

An image is only useful as determinism if the same image produced the baselines you compare against. Record the image digest in the baseline manifest — the same manifest that already carries style hash, zoom, center, and DPR in Baseline Management for Tile Servers. Then have the test refuse to run against baselines generated by a different environment fingerprint:

import { readFileSync } from "node:fs";

const manifest = JSON.parse(readFileSync("baseline/home-z12.json", "utf8"));
const currentImage = process.env.MVR_IMAGE_DIGEST; // injected at container run

if (manifest.imageDigest !== currentImage) {
  throw new Error(
    `Baseline built on ${manifest.imageDigest}, running on ${currentImage}. ` +
      `Re-bless baselines or run the matching image.`
  );
}

This turns “passes locally, fails in CI” from a mystery into an assertion that fires before the diff, naming the environment mismatch outright. When you deliberately upgrade the image, you re-bless baselines and the manifest records the new digest — the same layered re-bless discipline the parent guide applies to style changes.

6. Cache the image in CI

Build the image once and pull it everywhere, rather than reinstalling fonts and browsers on every job. Push the built image to a registry under a pinned tag (or better, reference it by digest), and have the CI job run inside it. Because the Dockerfile ordered the expensive layers first, a change to test code reuses the cached font and locale layers and only rebuilds the top.

name: map-visual
on: [pull_request]

jobs:
  render:
    runs-on: ubuntu-24.04
    container:
      image: ghcr.io/acme/map-test@sha256:1a2b...c3d4
      env:
        MVR_IMAGE_DIGEST: sha256:1a2b...c3d4
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run baselines:pull
      - run: npm run test:visual
      - if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: diff/

Running the job inside the pinned image means the runner’s own OS, fonts, and timezone are irrelevant — the container supersedes them. This is where the environment layer hands off to the broader CI gating discipline covered in GitHub Actions & GitLab CI Gates for Map Visual Tests, which owns retries, sharding, and the pass/fail policy on top of this image.

Cross-browser and cross-environment considerations

One image pins one browser well, but a map suite that claims cross-browser coverage needs the environment story to hold across engines and host platforms.

  • Chromium is the baseline engine and the one SwiftShader serves cleanly; --use-gl=angle --use-angle=swiftshader is the reliable software path. This is the engine your primary baselines should target.
  • WebKit in the Playwright image does not honor the Chromium GL flags. Its compositing and font hinting differ, so it needs its own baseline set, never a shared one. The same image still pins its fonts and locale, which removes two of the three divergence axes even though the GL path differs.
  • Firefox rasterizes vector tiles on a different schedule and hints text differently again; treat it as a third baseline lane. Maintaining these separate lanes deliberately is the job of the Cross-Browser Baseline Matrix, which the container makes tractable by holding every non-GL axis constant so only the engine varies.
  • Host platform. The container erases host-OS differences for rendering, but Docker Desktop on macOS and Windows runs a Linux VM whose CPU features differ from a native Linux runner. SwiftShader is pure-software and CPU-deterministic in practice, but confirm by diffing two captures of the same commit across a macOS laptop and a Linux CI node before trusting a shared baseline.
  • Architecture. An Apple Silicon laptop builds arm64 images while most CI runs amd64. Pin the platform explicitly — docker build --platform=linux/amd64 — so a developer on arm64 reproduces the exact rasterization CI uses, rather than an architecture-specific SwiftShader build.

The residual sub-pixel noise that survives even a perfectly pinned environment — a stray anti-aliased edge, a one-off WebGL dither — is absorbed downstream by Noise Reduction for Map Artifacts rather than by loosening the environment.

Environment axes reference

Every axis that moves map pixels, why it matters, and where to pin it in the image or launch config.

Environment axis Why it moves pixels How to pin it
Base image Bundles the browser build and system libs; a mutable tag drifts FROM …@sha256: digest, not a tag
Fonts Missing face → fallback → every label shifts sub-pixel fonts-noto-core, fonts-noto-cjk, then fc-cache -f
Locale Digit shaping and separators in scale bars and readouts locale-gen en_US.UTF-8; LANG/LC_ALL; context locale
Timezone Date-bound labels shift across a TZ boundary TZ=UTC; /etc/localtime → UTC; context timezoneId
GL backend GPU vs software dither, anti-alias, and shader compile differ --use-gl=angle --use-angle=swiftshader --disable-gpu
Browser build Skia/hinting changes move label placement Fixed by the pinned Playwright base version
Color profile Non-sRGB management shifts every color --force-color-profile=srgb
Device pixel ratio DPR-2 doubles tiles and changes anti-aliasing deviceScaleFactor: 1; document DPR-2 separately
Font hinting Sub-pixel glyph positioning varies by hint mode --font-render-hinting=none
CPU architecture arm64 vs amd64 SwiftShader builds can differ docker build --platform=linux/amd64

Common pitfalls

Baselines diff everywhere after an unpinned base-image rebuild

Root cause: the Dockerfile used a mutable tag like playwright:v1.44.0-jammy, and the registry repointed it to a rebuild with a newer Skia or font library, changing glyph hinting across the whole frame. Diagnose: compare the sha256 the tag resolves to now against the digest recorded when baselines were blessed; a mismatch confirms it. Fix: pin FROM …@sha256: by digest, record that digest in the baseline manifest, and treat any change as a deliberate re-bless.

CJK or non-Latin place names render as tofu boxes

Root cause: fonts-noto-cjk (or the relevant script package) is not installed, so the browser has no glyph for those code points and paints .notdef boxes. Diagnose: run fc-list | grep -i noto inside the container; a missing CJK entry is the tell. Fix: add fonts-noto-cjk to the font layer, rerun fc-cache -f, and re-bless the affected baselines. Emoji labels need fonts-noto-color-emoji for the same reason.

Gradients and halos dither differently between laptop and CI

Root cause: one environment used the host GPU while the other fell back to software, so WebGL rasterization diverged. Diagnose: capture the same commit in both and diff; smooth-gradient and label-halo regions showing scattered edge noise point at the GL backend. Fix: force --use-gl=angle --use-angle=swiftshader --disable-gpu everywhere so rasterization is pure-software, and confirm no environment silently re-enables hardware acceleration.

Locale or timezone leaks through despite the Dockerfile setting it

Root cause: the OS locale was pinned but the browser context was not, so Intl.NumberFormat or a Date label inside the page used a different default from native rendering. Diagnose: log Intl.DateTimeFormat().resolvedOptions().timeZone inside page.evaluate; a non-UTC value confirms the leak. Fix: set locale and timezoneId on the Playwright context in addition to LANG/TZ in the image, so both the native and JavaScript formatting paths agree.

The image rebuilds fonts and browsers on every CI run

Root cause: the job installed fonts and ran playwright install as pipeline steps instead of running inside a pre-built image, or layer ordering put a frequently-changing COPY before the apt-get layer and busted the cache. Diagnose: check whether the font install step re-runs on a test-only change; if it does, the cache is not being reused. Fix: build the image once, push it to a registry, run the job inside the pinned digest, and order the Dockerfile so fonts and locale sit below the app-code COPY.

Frequently asked questions

Why pin the base image by digest instead of a version tag?

A tag such as playwright:v1.44.0-jammy is mutable — the registry can repoint it to a rebuilt image with a patched font library or a newer Skia, which changes glyph hinting and moves every label. A sha256 digest is immutable, so pulling it always yields the exact bytes your baselines were blessed against. Record the digest in the baseline manifest and treat any change as a deliberate re-bless.

Do I need the container if I already force SwiftShader in the browser?

No — SwiftShader fixes only the GL backend. Fonts, locale, and timezone still come from the host, so a missing font package or a non-UTC runner still shifts pixels even with software GL. The container pins all of those axes together as one versioned artifact, which is why it is the right unit of determinism rather than any single flag.

Can one image serve Chromium, WebKit, and Firefox baselines?

One image can host all three engines, but they do not share a baseline. Only Chromium honors the SwiftShader GL flags; WebKit and Firefox composite and hint text differently and need their own baseline lanes. The image still pins fonts, locale, and timezone identically for all three, so only the engine varies — which is exactly what makes a cross-browser baseline matrix tractable.

← Back to Web Map Visual Testing Fundamentals & Toolchains