ScreenshotNeo

BlogHow-to

How to Keep Screenshot Tests Stable When Using Custom Fonts in Docker

Make custom fonts predictable in Docker screenshot tests by installing and verifying them, waiting for web fonts, and pinning the rendering environment.

By the ScreenshotNeo team4 October 20269 min read

To reduce font-related screenshot drift, make font availability part of the test image: include the exact licensed font files, refresh the font cache, and verify the browser can resolve the requested family inside the container. If the page loads fonts from the web, wait until those fonts are ready before capturing. Keep the container image, browser build, viewport, and other rendering inputs consistent between baseline creation and CI.

Docker helps control the environment, but it does not guarantee identical pixels across every host or graphics stack. When a diff appears, first find out which font the browser selected; widening the pixel threshold can hide the cause without fixing it.

1. Put the font in the image that runs the browser

Copy the font files into the final test image, in a directory Fontconfig searches. Installing fonts in a build stage does not help if the test browser runs in a different stage or container. Fontconfig provides font configuration and matching, including application-provided font directories. Check the font’s license before copying it into an image you distribute. See the Fontconfig documentation.

For a Debian-based image with Fontconfig installed, a simple Dockerfile pattern is:

FROM mcr.microsoft.com/playwright:v1.55.0-noble

USER root
RUN apt-get update && apt-get install -y --no-install-recommends fontconfig \
    && rm -rf /var/lib/apt/lists/*

# Keep only fonts you are licensed to include.
COPY fonts/ /usr/local/share/fonts/project/
RUN fc-cache -f -v /usr/local/share/fonts/project

# Add the test project and install its dependencies as appropriate.
WORKDIR /work
COPY package*.json ./
RUN npm ci
COPY . .

CMD ["npx", "playwright", "test"]

The tag above illustrates pinning a base image; choose a tag supported by your project and keep it the same for baseline generation and CI. For stronger reproducibility, pin the image digest as well. The font installation commands assume a Debian-family image with apt and Fontconfig; other distributions use different package managers and font directories.

fc-cache scans configured font directories and builds font information caches. Run it after adding fonts, then inspect the family from inside the final image. Refer to the fc-cache manual.

# Run against the image and user account used by the tests.
fc-list : family style | sort
fc-match "Acme Sans:style=Regular"
fc-match "Acme Sans:style=Bold"
fc-match sans-serif

Replace Acme Sans with the family name embedded in the font metadata, which may differ from its filename. A successful file copy is not proof that the expected family and weight are discoverable. If fc-match reports an unexpected fallback, verify the path, file readability, metadata family name, cache refresh, and configured Fontconfig directories.

2. Check fallback and glyph coverage

Browsers can use a fallback when the requested family is unavailable. This can happen for the whole page, one weight, or only characters missing from the chosen font. Check regular, bold, italic, and any variable-font axes the page actually uses. For multilingual pages, verify the scripts in the test data; a family may render Latin text correctly and still fall back for another script. Icon fonts also need their exact files and glyph coverage.

Use an explicit family in the page or test fixture when the design requires one, and make the fallback stack intentional:

body {
  font-family: "Acme Sans", Arial, sans-serif;
}

Then verify that the intended family resolves inside the container and that the browser has loaded it. A generic match such as sans-serif only tells you which generic fallback Fontconfig selects; it does not establish that the app’s named font is available.

Cloudflare’s managed-browser documentation also notes that Chromium falls back when a requested font is unavailable. The same practical diagnostic applies to self-managed containers: confirm what is available before interpreting a visual diff. See Cloudflare’s custom fonts documentation.

3. Wait for page-loaded web fonts before capture

System-installed fonts and web fonts are separate inputs. Installing a font into Docker does not guarantee that a page using a remote @font-face URL has loaded it. Conversely, waiting for page fonts cannot make an inaccessible remote font URL succeed.

In Playwright, wait for the document’s font set and optionally check a representative text run before taking the screenshot:

import { test, expect } from '@playwright/test';

test('page renders with the expected font', async ({ page }) => {
  await page.goto('http://127.0.0.1:3000/example', {
    waitUntil: 'domcontentloaded',
  });

  await page.evaluate(async () => {
    await document.fonts.ready;
  });

  const fontLoaded = await page.evaluate(() =>
    document.fonts.check('16px "Acme Sans"', 'Hamburgefontsiv 0123')
  );
  expect(fontLoaded).toBe(true);

  await page.screenshot({ path: 'artifacts/example.png', fullPage: true });
});

This uses the browser Font Loading API through Playwright’s page evaluation. Confirm the APIs against the browser and Playwright versions in your project. A positive check is useful evidence for the supplied family and sample text, but it does not prove every weight or glyph on the page is covered. Add representative checks for the styles and scripts that matter.

If the test uses a remote font, also verify that CI can reach its host and that the font response succeeds. For tighter control, serve the font from the test fixture or install the same font files in the image and use them locally, subject to the font license.

4. Keep the rendering inputs consistent

Generate baselines and run comparisons with the same container image and browser build. Fix the viewport, device scale factor, locale, timezone, and relevant browser flags. Keep test data and page state stable as well. If the application uses animation, timestamps, randomized content, or network-loaded assets, control those inputs separately so a font fix is not obscured by unrelated changes.

Input What to keep consistent Why it matters
Font files Same file versions, family metadata, styles, and glyph coverage A changed or missing face can change glyph shapes, line wrapping, and layout.
Container and OS packages Same image tag or digest and installed rendering packages System font discovery and rendering dependencies can differ across images.
Browser Same browser build and launch configuration Browser updates can change text shaping and rasterization.
Viewport and scale Same viewport dimensions and device scale factor Text wrapping and pixel dimensions depend on these settings.
Page font source Same network access, responses, and font-loading state A remote font can fail or finish loading at a different point in CI.
Locale and timezone Same values if the page formats locale-sensitive content Text and layout can vary with localized content.

Docker visual-testing guides commonly demonstrate pinning the browser image and viewport. Treat old sample tags in third-party guides as examples of the technique, not current version recommendations. A container also cannot promise identical pixels across every host or graphics stack.

5. Make font-cache generation reproducible where needed

If your build process depends on reproducible Fontconfig cache generation, Fontconfig documents SOURCE_DATE_EPOCH as a timestamp source that fc-cache can use instead of font modification times when deciding whether cache data needs regeneration. For example:

ENV SOURCE_DATE_EPOCH=1700000000
RUN fc-cache -f -v /usr/local/share/fonts/project

Choose the epoch according to your build policy rather than copying the example value blindly. This can make cache-generation inputs more reproducible; it does not freeze the browser renderer or make screenshot PNGs deterministic by itself. See the Fontconfig documentation.

6. Diagnose a visual diff in order

  1. Reproduce inside the test image. Run the diagnostic as the same user and in the same final image that executes the browser suite.
  2. Check the actual family and styles. Use fc-match for the named family and requested styles; use fc-list to inspect discoverable families.
  3. Check the final image contents. Confirm the font files were copied into the runtime stage, are readable, and have the expected metadata.
  4. Refresh the cache. Run fc-cache after adding or changing font files, then repeat the match query.
  5. Check page font loading. Wait for the browser font set, check representative text, and inspect network failures if fonts are remote.
  6. Compare the environment. Check image digest, browser version, viewport, scale factor, locale, timezone, and launch settings against the baseline job.
  7. Review the baseline change deliberately. If a font or rendering input changed intentionally, review the new screenshots and document the baseline update.

Do not start by relaxing the pixel-diff threshold. First establish whether the browser selected the intended font. Otherwise, a fallback can become an accepted baseline by accident.

7. Troubleshooting

Symptom Likely cause Fix
CI wraps text differently from local The named font is missing or a different face is selected in the test image. Run fc-match in the final image; install the correct files, refresh the cache, and check family metadata and weight.
Font exists in the build but not in tests It was installed only in a build stage or another container. Copy the font into the final browser image or mount it into the actual runtime, then rebuild the cache there.
fc-match returns a fallback The family name is wrong, the font directory is not configured, the file is unreadable, or the cache is stale. Check the embedded family name, path, permissions, Fontconfig configuration, and run fc-cache -f.
Only bold or italic text differs The matching face or style is absent, so another face is synthesized or selected. Install and verify the specific styles used; query each style with fc-match.
Some characters use a visibly different design The font lacks glyphs for those characters or scripts. Check representative multilingual text and provide a licensed fallback with the required glyph coverage.
First capture differs; later capture matches The screenshot occurred before a web font finished loading. Wait for document.fonts.ready, check expected font availability, and capture afterward.
Remote web font is absent only in CI Network access, DNS, TLS, authentication, or the remote response differs in CI. Inspect the font request and response in the CI browser; allow the host or serve a controlled local copy.
Diffs appeared after an image or browser update The OS, browser, graphics dependencies, or font package changed. Compare image digests and browser versions; review and update baselines only when the change is intended.
Cache rebuild does not change the match The font may be outside configured directories or its metadata does not match the requested family. Inspect fc-list, Fontconfig paths, and the font’s internal family/style metadata.

8. Performance, reliability, and cost

Installing fonts and refreshing Fontconfig’s cache are image-build tasks; doing them once when building the test image avoids repeating setup for every test capture. Pinning the image and browser also makes failures easier to reproduce. Waiting for web fonts adds only the time needed for fonts to become ready, but a remote font can make runs dependent on network availability and response time. A local, licensed font source reduces that dependency.

For a reproducible pipeline, record the font file versions, container image digest, browser build, viewport, and relevant browser settings alongside the visual baseline. When one of these changes, treat the resulting screenshot review as a deliberate baseline update. Docker narrows environmental variation; it does not remove differences between all hosts and graphics stacks.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF. It is useful when you need a rendered page capture without maintaining a browser container. It does not replace a controlled local visual-regression suite when you need to pin the exact browser and font files that produced a baseline.

Its captures remove cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, with verdict and billing details in response headers. AI agents can capture through its MCP server using Claude, Cursor, or another MCP client. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Example cURL request:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

See the ScreenshotNeo API documentation for the request options and response details.

Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.

FAQ

Does Docker guarantee identical screenshot pixels?

No. It helps keep the operating system, fonts, and browser environment consistent, but does not promise identical output across every host or graphics stack.

Should I install a font or load it from the page?

Use the source your application actually relies on, then make that source available and deterministic in CI. System-installed fonts suit controlled test assets; page-loaded fonts should be checked for successful loading before capture.

Can I use a wider visual-diff threshold to handle font drift?

Only after identifying and accepting the rendering change. First verify the selected family, styles, glyph coverage, and browser environment so a missing font is not silently accepted.

Does a successful font-family check prove all text uses that font?

No. Check representative text, weights, and scripts. Missing glyphs can still come from a fallback even when the family is available.