Cross-OS rendering
How Frontguard's Dockerized renderer produces byte-equivalent baselines regardless of developer OS.
Cross-OS rendering
Frontguard captures pixel screenshots and compares them against a stored baseline. That comparison is exact: even a single anti-aliased glyph that shifts by a sub-pixel will trip the diff threshold and flag a regression. So the question that defines whether visual regression testing is useful or maddening becomes: does the same page rendered on two different machines produce the same bytes?
The answer, by default, is no.
This page explains why, what Frontguard does about it, and how to wire the Dockerized renderer into your workflow so a baseline captured on your macOS laptop matches a screenshot taken on a Linux CI runner — byte for byte.
The pinned image and the --docker flag are part of Frontguard's IN-7 milestone. The renderer plumbing is stable, but the frontguard/render image is not yet published to a registry for this release — you build it locally first (see Building the image locally). Registry publication is tracked as a launch follow-up in docs/ops-actions.md. The same image is what Frontguard's cloud runner (post-T6) uses, so once it's built, a baseline captured locally with --docker matches a cloud run with no extra configuration.
The cross-OS problem
Playwright is rigorous about pinning the browser binaries — every Playwright release ships its own bundled Chromium, Firefox, and WebKit. So you'd reasonably expect two machines running the same Playwright version to render a given URL identically.
They don't.
The reason is buried in Playwright's own documentation, and it has nothing to do with Playwright itself:
Note: Generated screenshots may differ on different operating systems. This is due to differences in font rendering, sub-pixel anti-aliasing, and emoji tables across platforms.
What's actually different:
- Font hinting and metrics. macOS, Linux, and Windows each ship different font rendering pipelines. CoreText on macOS, FreeType on Linux, and DirectWrite on Windows produce visibly different glyph edges for the same font file. The differences are small (a few sub-pixels per glyph) but they're per glyph — so a paragraph of body text accumulates dozens of byte-level diffs.
- System fonts. "Arial" on macOS is a different
.ttffile than "Arial" on Ubuntu. The metrics are similar but not identical, which shifts line-wrapping and pushes text to different pixel positions. - Emoji. Apple Color Emoji, Noto Color Emoji, and Segoe UI Emoji are entirely different glyph designs. A 🎉 on macOS and a 🎉 on Linux are different bitmaps.
- Sub-pixel anti-aliasing. macOS and Linux make different defaults around LCD subpixel rendering. macOS aggressively anti-aliases; many Linux configurations disable it.
- GPU compositing. Chromium's rasterizer takes slightly different paths depending on the host GPU and driver, even with
--disable-gpu. The CPU rasterization path is itself slightly different on different libc/kernel combinations.
The cumulative result: a baseline captured on darwin-arm64 and compared against a screenshot from linux-x86_64 will diff every single time, with diff percentages typically in the 0.3% to 1.5% range — small enough that you can't simply raise the threshold to ignore them, large enough that they flood every PR with false positives.
This is why every serious visual-regression product — Chromatic, Percy, Applitools — runs all rendering in their own cloud, on their own pinned VMs. They never give the developer a choice. Frontguard takes a hybrid approach: by default we render on the developer's box (because that's the fastest local feedback loop), and we offer --docker as the deterministic mode for when you need byte-equivalent baselines.
If your team has multiple operating systems (macOS dev + Linux CI is the canonical case), you must use --docker for baseline capture and verification, or your CI will flag false-positive regressions on every PR. There is no threshold setting that makes mixed-OS baselines stable.
What --docker does
When you pass --docker to frontguard run, the CLI:
- Verifies that
dockeris installed on PATH. If not, it surfaces a single clear error message (not a Node stack trace) pointing at the install instructions. - Bind-mounts your current working directory at
/workspaceinside the container. - Forwards your AI provider keys (
FRONTGUARD_OPENAI_KEY,FRONTGUARD_ANTHROPIC_KEY,FRONTGUARD_GEMINI_KEY) and telemetry settings throughdocker run -e KEY— by name, not value, so secrets never enter the argv list. - Adds a
host.docker.internal:host-gatewayalias on Linux so--url http://host.docker.internal:3000reaches your dev server regardless of host OS. - Strips the
--dockerflag from the forwarded argv (otherwise the container would recursively re-invoke itself). - Re-executes the CLI inside the pinned
frontguard/renderimage. - Mirrors the container's exit code so CI gates see the right signal.
The container's entrypoint is the @frontguard/cli binary that was packed into the image at build time. Your frontguard.config.{ts,js,json} is read from /workspace, screenshots are written back to your project's output directory via the bind mount, and the run otherwise behaves identically to a local run — except the rendered bytes are reproducible.
The image
The Dockerfile lives at packages/cli/docker/Dockerfile in the Frontguard monorepo. The image must be built locally today (see Building the image locally); registry publication under the frontguard/render:vX.Y.Z tag — matching the @frontguard/cli version — is tracked as a launch follow-up.
What's pinned:
- Base:
mcr.microsoft.com/playwright:v1.59.0-jammy. Microsoft publishes one tag per Playwright release; the browsers (Chromium, Firefox, WebKit) and their patch level are baked into that tag. We never uselatest— that would silently change rendered output across builds. - Fonts: Liberation (Microsoft-metric Arial/Helvetica/Times substitutes), DejaVu (broad Unicode coverage), Noto Core, Noto CJK, Noto Color Emoji. Installed from Ubuntu Jammy's apt archive with
fc-cache -fafter install, so the fontconfig cache is warm at runtime. - The CLI itself: installed from a local
npm packtarball, not from npmjs. This means the image self-contains a known build — there's no late-binding network call tonpm install -g frontguard@latestthat could shift behavior across rebuilds.
The version-pin policy is documented in the Dockerfile's header comment. When the playwright version in packages/cli/package.json is bumped, the FROM line in the Dockerfile is bumped in the same commit. The build is intentionally minimal — fewer moving parts means fewer surprises.
Building the image locally
The frontguard/render image is not yet published to Docker Hub or GHCR for this release — registry publication is tracked as a launch follow-up in docs/ops-actions.md (in the repo). Until it lands, build the image yourself. From the repo root:
# 1. Pack the CLI into the tarball the build context expects:
(cd packages/cli && npm pack && mv frontguard-cli-*.tgz docker/frontguard-cli.tgz)
# 2. Build the image, pinning the architecture:
docker build --platform linux/amd64 \
-t frontguard/render:v0.2.0 -t frontguard/render:latest \
packages/cli/docker--platform linux/amd64 is required, not optional. Chromium's rasterizer produces different sub-pixel anti-aliasing on arm64 vs amd64, so an arm64-built image would not be byte-equivalent to the amd64 image your CI (and the cloud runner) use. frontguard run --docker injects the same --platform linux/amd64 into docker run, so on Apple Silicon the container runs under Rosetta/qemu emulation — correct bytes, but expect runs to be roughly 3–5× slower than a native arm64 container would be.
Once built, the image is cached locally and frontguard run --docker reuses it. If you skip this step, the CLI preflights the image and fails fast with the exact docker build command to run, rather than a cryptic pull access denied. When the registry-publish step lands, this build becomes optional — --docker will pull the published tag automatically.
Usage
Day-to-day: build once, then run with --docker
First, build the render image locally. This is a one-time step until registry publication lands (see Building the image locally):
(cd packages/cli && npm pack && mv frontguard-cli-*.tgz docker/frontguard-cli.tgz)
docker build --platform linux/amd64 -t frontguard/render:latest packages/cli/dockerThen, if you have Docker Desktop (or any modern Docker) installed, pass --docker to your normal command:
frontguard run --url http://host.docker.internal:3000 --dockerOn macOS and Windows, host.docker.internal is resolved automatically. On Linux, Frontguard adds the host-gateway alias so the same URL works.
frontguard run --docker does not pull frontguard/render from Docker Hub yet — the image is not published for this release. If you skip the build step above, the CLI preflights the image and fails fast with the exact docker build command to run, rather than a cryptic pull access denied. Subsequent runs reuse the locally cached image (~600 MB). When the registry-publish step lands, the first --docker run will pull the image automatically and the manual build becomes optional.
Pinning a specific image version
By default the CLI runs frontguard/render:latest. To pin a specific version (recommended for CI), set the FRONTGUARD_DOCKER_IMAGE environment variable:
# Assumes you've already built (or, once published, pulled) this exact tag
# locally — e.g. `docker build --platform linux/amd64 -t frontguard/render:v0.2.0 packages/cli/docker`.
export FRONTGUARD_DOCKER_IMAGE=frontguard/render:v0.2.0
frontguard run --url http://host.docker.internal:3000 --dockerThis is the same variable the cloud runner reads, so once you pick a tag, you can use the same value in local CI scripts and cloud configs.
Capturing baselines in Docker
Baselines must be captured in the same environment as the comparison runs. If your CI runs frontguard run --docker, your baselines must also be captured with --docker:
frontguard run --url http://host.docker.internal:3000 --docker --update-baselines
git add .frontguard/baselines
git commit -m "chore: refresh baselines"The bind mount writes the new baselines back to your working tree, so the commit lives in your repo — exactly as if you had run locally.
Compose: a recipe for AI fix verification
The repo also ships a docker-compose.yml next to the Dockerfile. It mirrors the bind mount and env-var forwarding and is the easiest path when you also want to forward AI keys:
export FRONTGUARD_OPENAI_KEY=sk-...
docker compose -f node_modules/@frontguard/cli/docker/docker-compose.yml run --rm frontguard \
run --url http://host.docker.internal:3000 --generate-fixesThe compose file allow-lists the env vars Frontguard cares about and passes them by name, so the values never appear in docker inspect or ps output.
Trade-offs
--docker is not free. Here's what you're paying:
Build time and image size
The pinned image is ~600 MB compressed. The first pull on a cold machine takes 30–90 seconds depending on network. Subsequent runs reuse the cached image and the per-run overhead is ~1–2 seconds (the time for docker run to start a container).
The local Playwright path has zero startup overhead — it just spawns browsers. So for tight inner-loop iteration on a single OS, the local path is faster. The general guidance:
- Local dev, single OS, exploratory runs: the default local renderer is fine.
- Baseline capture, CI, anything you'll diff against later: use
--docker.
Network topology
In Docker, localhost means "the container itself," not "your host machine." You have to use host.docker.internal (resolved automatically on macOS/Windows and via the host-gateway alias Frontguard injects on Linux) to reach your dev server.
If your dev server binds to 127.0.0.1 instead of 0.0.0.0, the container won't be able to reach it. Either bind to 0.0.0.0 or rely on the gateway alias.
File permissions
The bind mount runs the container as root by default (the Microsoft Playwright base image's standard). Files written into /workspace will be owned by root from your host's perspective. On Linux, this can cause permission issues when your subsequent local commands try to read those files.
The workaround: pass --user $(id -u):$(id -g) to docker run (or set user: in the compose file) to align UIDs. Frontguard's compose recipe doesn't set this by default because it breaks the Microsoft base image's pre-installed browser ownership; a future image revision will isolate the cache properly.
AI keys in CI
When you forward FRONTGUARD_OPENAI_KEY and friends to the container, they're available to anything running inside. The CLI itself only uses them for AI calls, but if you're chaining custom plugins inside the same container, those plugins will also see the keys. This is true of any subprocess on the host too, so it's not a new exposure — but it's worth knowing.
How baseline inheritance depends on this
Frontguard's baseline-inheritance model — where a feature branch inherits baselines from main and diffs the delta — assumes that the baseline and the new screenshot were produced in the same environment. Otherwise the "delta" is dominated by OS rendering noise, not by your actual change.
Concretely:
- If
main's baseline was captured on macOS (developer's laptop) and the PR runs on Linux CI without--docker, every screenshot diffs by 0.3–1.5%, every PR is red, and the inheritance model breaks down. - If
main's baseline was captured with--docker(any OS) and the PR runs with--docker(any OS), the only diff is the actual visual change introduced by the PR.
So --docker is not just a CI nicety. It's the foundation that makes the inheritance model — and therefore Frontguard's PR workflow — usable on heterogeneous teams. Once a project standardizes on --docker for baseline capture, the inheritance model becomes deterministic.
How the cloud uses the same image
Frontguard's cloud runner (the Daytona-backed compute path introduced in T6) consumes the same image tag that you'd use locally. There's no separate "cloud image" — frontguard/render:vX.Y.Z is the single source of truth.
This means:
- A baseline captured locally with
frontguard run --docker --update-baselinesmatches a cloud-captured baseline, byte for byte — across the same architecture. The Dockerfile pins--platform linux/amd64so arm64 dev machines and amd64 CI runners produce byte-equivalent output; the trade-off is that local--dockerruns on Apple Silicon execute under Rosetta/qemu emulation and are roughly 3–5× slower than a native container. - The cloud's pipeline output for the same URL, same viewport, same browser, same revision is reproducible on any developer's box that has Docker.
- Pinning
FRONTGUARD_DOCKER_IMAGE=frontguard/render:v0.2.0in your CI config and Cloud-API project settings keeps the two in lockstep across versions.
The cloud and the local --docker mode are not "approximately the same" — they are the same.
Troubleshooting
"Docker is required for --docker mode but docker was not found on PATH"
Install Docker Desktop (macOS/Windows) or your distro's Docker package (Linux). The Frontguard CLI looks for docker on PATH; if you've installed via a non-default location, add it to your shell's PATH and re-run.
"frontguard: not found" inside the container
This happens if you've overridden the entrypoint. The image's ENTRYPOINT is frontguard and CMD defaults to run. If you've passed --entrypoint to docker run, you'll need to invoke the binary explicitly.
Dev server unreachable from inside the container
You're probably binding to 127.0.0.1. Re-bind to 0.0.0.0:
- Vite:
vite --host 0.0.0.0 - Next.js:
next dev -H 0.0.0.0 - Create React App:
HOST=0.0.0.0 npm start
Then use http://host.docker.internal:<port> as your --url.
Baselines look different after switching to --docker
This is expected on the first --docker run. Your old baselines were captured outside the image and will diff against the new in-image renders. Re-capture with --update-baselines once, commit the new baselines, and from that point forward every machine using --docker will see byte-equivalent renders.
CI is slow because of image pulls
Cache the image in your CI. GitHub Actions example:
- name: Cache Frontguard image
uses: actions/cache@v4
with:
path: /tmp/frontguard-image.tar
key: frontguard-image-${{ env.FRONTGUARD_VERSION }}
- name: Build (or load cached) Frontguard image
run: |
if [ -f /tmp/frontguard-image.tar ]; then
docker load -i /tmp/frontguard-image.tar
else
# The image is not on a registry yet, so build it from the repo.
# Once frontguard/render is published, replace the build with:
# docker pull frontguard/render:${{ env.FRONTGUARD_VERSION }}
(cd packages/cli && npm pack && mv frontguard-cli-*.tgz docker/frontguard-cli.tgz)
docker build --platform linux/amd64 \
-t frontguard/render:${{ env.FRONTGUARD_VERSION }} packages/cli/docker
docker save frontguard/render:${{ env.FRONTGUARD_VERSION }} -o /tmp/frontguard-image.tar
fiThis caches the built image, so only the first (cold) run pays the build cost; warm runs load the tarball in ~3s.
The image is too big to download in my environment
You can build it locally:
git clone https://github.com/ravidsrk/frontguard.git
cd frontguard/packages/cli
npm pack
mv frontguard-*.tgz docker/frontguard-cli.tgz
docker build --platform linux/amd64 -t frontguard/render:dev docker/
FRONTGUARD_DOCKER_IMAGE=frontguard/render:dev frontguard run --docker --url http://host.docker.internal:3000The locally built image is byte-equivalent to the published one as long as you're on the same @frontguard/cli version, you build with the same --platform linux/amd64, and you haven't modified the Dockerfile.
Why we built this (and didn't punt to the cloud)
Most visual-regression products solve the cross-OS problem by simply not giving you a choice — all rendering runs in their cloud. That works, but it has costs: you pay for compute on every run, your CI sits on the network waiting for a remote pipeline, and offline / air-gapped environments can't use the tool at all.
Frontguard's bet is that local-first with optional cloud is the right model for visual regression, the same way it is for unit tests. You should be able to:
- Run on your laptop, in your normal dev loop, with no network.
- Run in CI on your own infrastructure, with no per-run cost.
- Run in our cloud when you want managed compute and centralized reporting.
All three modes need to produce identical bytes, or the model collapses. --docker is the local-and-CI half of that. The cloud is the third leg, and it uses the same image.
That's why we maintain a pinned, reproducible image rather than letting users roll their own. The version pin matters, the font set matters, and the fact that the same image is what the cloud runs matters more than any of the individual technical details.
See also
- CLI reference:
frontguard run— every flag, including--docker. - CI/CD: GitHub Actions — wiring
--dockerinto a Linux CI runner. - Playwright plugin setup — using the same image tag in cloud runs.
- The Dockerfile itself, in
packages/cli/docker/Dockerfile, has the canonical version-pin policy in its header comment.