Running Accessibility Scans Inside a Container
This guide is part of CI/CD Integration & Automated Quality Gating. It covers one narrow engineering goal: making an automated accessibility scan return the same violation list twice, so that when the list changes the only possible explanation is that the application changed. Everything else in a gating strategy — thresholds, severity policy, branch protection — depends on that property, because a gate that produces a different answer on Tuesday than it did on Monday gets marked non-required within a fortnight.
Problem Statement
An accessibility scanner is not a static analyser. axe-core does not read your source; it walks a live accessibility tree that a specific browser build computed from a specific render of a specific set of fonts. The scanner’s job is to interrogate getComputedStyle, elementsFromPoint, the accessible-name computation and the layout box of every candidate node. Every one of those is browser output, not application output. Change the browser and you change the input to the rule, without touching a line of application code.
The most visible version of this is the color-contrast rule. To evaluate a text node, axe-core has to answer “what colour is actually behind this text”, which it does by walking up the ancestor chain and, when an ancestor is semi-transparent or overlapping, by hit-testing at the node’s midpoint and compositing the stack it finds. A minor Chromium bump that changes how a backdrop-filter, a mix-blend-mode layer or a fractional-pixel border is composited will shift the resolved background colour in the third decimal place, and a design token that lands on 4.50:1 by intent will resolve to 4.49:1. The rule’s threshold for normal-size text under WCAG 2.2 SC 1.4.3 (Contrast Minimum) is a hard comparison, so a sub-pixel compositing change becomes a serious violation on a pull request that only edited a README.
Accessible-name output drifts the same way and for the same reason: the name is computed by the browser, not by the scanner. Chromium’s implementation of the accessible-name specification is a moving target in exactly the places application code likes to sit — labels inside display: contents wrappers, aria-labelledby pointing at a node that is visibility: hidden, <slot> content projected across a shadow boundary, the fallback name of <input type="file"> and <summary>. A build that starts respecting a previously ignored aria-labelledby reference will remove a button-name violation; a build that starts ignoring a title attribute will add one. Both are indistinguishable, in a CI log, from a developer breaking or fixing the markup.
Fonts are the second uncontrolled input, and they are less obvious because nobody thinks of a font as a test dependency. A stylesheet that asks for Inter on a machine that does not have Inter gets whatever fontconfig substitutes, with different advance widths and different line-box heights. The scanner does not measure the font directly, but every measurement it makes downstream of layout changes: a label that fitted on one line now wraps to two and is clipped by an overflow: hidden parent, so axe-core treats the node as not rendered and stops testing it; a heading shifts eleven pixels down and now sits over the hero image instead of the flat panel above it, so the resolved background for the contrast calculation is different; a translated build with no CJK font renders every character as a .notdef box, which looks fine to the rules and useless to a human reviewing a screenshot.
The third input is capability, not version. Chromium’s multi-process sandbox needs kernel facilities that a default container job does not necessarily grant, so the browser either fails to launch or is launched with --no-sandbox by whoever debugged it fastest. Shared memory is the same class of problem: Docker gives a container 64 MB of /dev/shm by default, Chromium uses /dev/shm for its cross-process transport, and a media-heavy page or a scan that opens several pages in one browser exhausts it and dies with a renderer crash that surfaces as Target closed.
Containerising the scan does not make accessibility testing better. It makes it decidable. When the browser revision, the axe-core version, the font set, the locale, the timezone and the shared-memory budget are all properties of an image digest, a change in the violation list has exactly one cause left. That is the whole argument, and the rest of this guide is the mechanics.
Key implementation targets:
- One image that pins the Node runtime, the Chromium revision and the axe-core version as a single unit, so the scan result is attributable to an image digest.
- A container network in which the scanner reaches the application under test by service name, with a healthcheck gating the start of the scan.
- Chromium running as an unprivileged user with its sandbox intact, and a documented, narrowly scoped fallback for runners that cannot host it.
- A deterministic font and locale stack, including a guard that fails the scan when a webfont did not load.
- A shared-memory and process-reaping configuration that survives a small runner without random renderer crashes.
- A CI invocation whose exit code is the gate and whose report leaves the container as a host-owned artifact.
Prerequisites
1. Pin the Browser and the Scanner Together
Pin the pair, not the parts. A violation list is only meaningful as a tuple of (axe-core version, Chromium revision), because either half can change the answer. The practical way to pin the browser is to pin the driver: each playwright release declares exactly one Chromium revision, so a lockfile entry for playwright@1.47.2 transitively pins the browser download. npx playwright install chromium then fetches that revision and nothing else. Never apt-get install chromium, which resolves to whatever the distribution ships today and will roll forward under you on the next base-image rebuild.
Install the browser into a fixed path rather than $HOME/.cache, because the path has to be readable by the non-root user you will switch to later and stable across the layer boundary. PLAYWRIGHT_BROWSERS_PATH=/ms-playwright does that, and it also gives the browser its own cache-friendly layer that only changes when the lockfile changes — the layer-ordering discipline explored in detail in caching axe-core browser binaries in CI containers.
# syntax=docker/dockerfile:1.7
# Dockerfile.scan — the image IS the unit of reproducibility. Node, Chromium,
# fonts, locale and axe-core all move together, and only when this file or the
# lockfile changes.
FROM node:20.18.1-bookworm-slim AS scanner
ENV DEBIAN_FRONTEND=noninteractive \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
LANG=en_GB.UTF-8 \
LC_ALL=en_GB.UTF-8 \
TZ=UTC
# Fonts, locale data and fontconfig in their own layer: they change rarely and
# they are the layer most likely to be shared between the app and scan images.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates locales tzdata fontconfig \
fonts-liberation2 fonts-dejavu-core fonts-noto-core fonts-noto-cjk \
&& sed -i 's/^# en_GB.UTF-8/en_GB.UTF-8/' /etc/locale.gen \
&& locale-gen en_GB.UTF-8 \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Manifests only: npm ci stays cached until a dependency actually moves.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# --with-deps installs the shared libraries Chromium links against. The browser
# revision itself is decided by the pinned playwright version in the lockfile,
# never by this command.
RUN npx playwright install --with-deps chromium \
&& chmod -R a+rX /ms-playwright
COPY scripts ./scripts
COPY dist ./dist
# Record the exact pair in the image so any report can be traced back to it.
RUN node -e "console.log(require('axe-core').version)" > /app/.axe-version \
&& node -e "const {chromium}=require('playwright'); \
console.log(chromium.executablePath())" > /app/.chromium-path
# Unprivileged user with a real home: Chromium needs a writable profile dir.
RUN useradd --create-home --uid 10001 scanner \
&& mkdir -p /out && chown scanner:scanner /out
USER scanner
ENTRYPOINT ["node", "scripts/scan.mjs"]
Tag pinning stops at the patch version; digest pinning stops at the byte. Once the image builds, record the base digest and use it in the FROM line so a rebuild six weeks later cannot silently pick up a rebuilt node:20.18.1-bookworm-slim with a newer glibc or a different font package version.
# Read the digest of the base image you tested against, then paste it into the
# FROM line as node:20.18.1-bookworm-slim@sha256:<digest>.
docker buildx imagetools inspect node:20.18.1-bookworm-slim \
--format '{{.Manifest.Digest}}'
# Record the resulting scan image digest alongside every report you keep.
docker image inspect a11y-scan:ci --format '{{index .RepoDigests 0}}'
2. Wire the Scanner to the App Under Test
The scan needs two containers: one serving the built application, one driving the browser. Putting them on the same user-defined bridge network gives the scanner DNS resolution for the app’s service name, which is the entire trick. The failure everyone hits once is using http://localhost:8080 inside the scanner container — inside that container localhost is the scanner’s own loopback interface, nothing is listening on it, and the scan dies with ECONNREFUSED after a confusing minute of “but the app is definitely running”.
Order matters as much as reachability. A scanner that starts the moment the app container starts will hit a socket that is bound but not yet serving, or worse, serving a partially warmed cache. Gate the scanner on a real healthcheck with depends_on: condition: service_healthy so the browser only opens when the app can return a document. The healthcheck must probe from inside the app container, which means the app image needs a tiny HTTP client — curl is worth the 2 MB.
# docker-compose.a11y.yml
# docker compose -f docker-compose.a11y.yml up \
# --abort-on-container-exit --exit-code-from scanner
name: a11y
services:
app:
build:
context: .
dockerfile: Dockerfile.app
# expose, not ports: only containers on a11y-net need to reach it.
expose: ["8080"]
environment:
# Binding to 127.0.0.1 inside a container makes the app unreachable
# from any other container on the network.
HOST: "0.0.0.0"
PORT: "8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
interval: 3s
timeout: 2s
retries: 20
start_period: 10s
networks: [a11y-net]
scanner:
build:
context: .
dockerfile: Dockerfile.scan
depends_on:
app:
condition: service_healthy # no scan before the app serves a document
# Host uid:gid, so the report on the bind mount is not root-owned. Falls
# back to the image's own unprivileged user when SCAN_UID is unset.
user: "${SCAN_UID:-10001}"
environment:
SCAN_ORIGIN: "http://app:8080"
SCAN_PATHS: "/,/checkout,/account/orders"
AXE_TAGS: "wcag2a,wcag2aa,wcag22aa"
# An overridden uid has no home directory; Chromium needs a writable one.
HOME: "/tmp"
# Chromium needs far more than Docker's default 64 MB of /dev/shm.
shm_size: "1gb"
init: true # reaps the zombie Chromium children a crash leaves
volumes:
- ./reports:/out
networks: [a11y-net]
networks:
a11y-net:
driver: bridge
The scan entry point reads its origin from the environment so the same image runs against a compose service, a preview deployment or a locally forwarded port without a rebuild. It also stamps the axe-core version and the browser version into the report, which is what makes a later “this violation is new” claim checkable.
// scripts/scan.mjs — one origin, several paths, one JSON report on /out.
import { writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import axe from 'axe-core';
const origin = process.env.SCAN_ORIGIN ?? 'http://app:8080';
const paths = (process.env.SCAN_PATHS ?? '/').split(',');
const tags = (process.env.AXE_TAGS ?? 'wcag2a,wcag2aa,wcag22aa').split(',');
// Empty by default: the sandbox stays on unless a runner truly cannot host it.
const sandboxArgs = process.env.A11Y_UNSAFE_NO_SANDBOX === '1' ? ['--no-sandbox'] : [];
if (sandboxArgs.length) console.warn('WARNING: Chromium sandbox disabled for this run');
const browser = await chromium.launch({
args: [
...sandboxArgs,
'--font-render-hinting=none', // identical rasterisation on every host
'--disable-lcd-text', // no subpixel AA, so screenshots diff cleanly
],
});
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'UTC',
viewport: { width: 1280, height: 900 },
reducedMotion: 'reduce', // transitions settle at once, so scans do not race
});
const report = {
engine: { axeCore: axe.version, browser: browser.version() },
results: [],
};
for (const path of paths) {
const page = await context.newPage();
const fontFailures = [];
page.on('requestfailed', (request) => {
if (request.resourceType() === 'font') fontFailures.push(request.url());
});
const response = await page.goto(origin + path, { waitUntil: 'domcontentloaded' });
if (!response?.ok()) throw new Error(`${path} returned ${response?.status()}`);
await page.getByRole('main').waitFor({ state: 'visible' });
await page.evaluate(() => document.fonts.ready); // metrics stable before scan
if (fontFailures.length) {
throw new Error(`fonts failed to load, metrics unreliable: ${fontFailures[0]}`);
}
const results = await new AxeBuilder({ page }).withTags(tags).analyze();
report.results.push({
path,
violations: results.violations.map((v) => ({
id: v.id, impact: v.impact, nodes: v.nodes.length,
})),
});
await page.close();
}
writeFileSync('/out/a11y.json', JSON.stringify(report, null, 2));
await browser.close();
const blocking = report.results
.flatMap((r) => r.violations)
.filter((v) => v.impact === 'critical' || v.impact === 'serious');
console.log(`${blocking.length} blocking violations across ${paths.length} paths`);
process.exit(blocking.length > 0 ? 1 : 0); // the container exit code is the gate
3. Keep the Sandbox and Drop the Root Privileges
Two separate things get conflated here: running the container process as a non-root user, and keeping Chromium’s own multi-process sandbox. They are independent, and you want both.
Chromium’s layer-one sandbox isolates the renderer — the process that parses HTML, runs JavaScript and decodes images, which is to say the process that touches untrusted input. On Linux it needs either the setuid chrome-sandbox helper or, in modern builds, unprivileged user namespaces. A container job fails to provide those in two different ways: the host kernel may have unprivileged user namespaces disabled (Debian historically shipped kernel.unprivileged_userns_clone=0; Ubuntu 22.04 and RHEL 9 enable them), or the container runtime’s seccomp profile may reject the clone flags the namespace sandbox uses. The error text is the same either way: No usable sandbox! Update your kernel.
--no-sandbox makes that message go away by deleting the isolation boundary. On content you built yourself, from your own repository, the exposure is bounded and many teams accept it. On untrusted content it is a genuine escape path, and “untrusted” is broader than it sounds: crawling customer sites, auditing third-party embeds, ad or analytics scripts on a staging build, and — most easily overlooked — a preview deployment built from a pull request opened by a fork, where an attacker chooses the JavaScript that the renderer executes. A renderer compromise in a --no-sandbox container has the container’s full privileges, which in a CI job means the registry credentials, the environment variables holding tokens, and, if someone mounted /var/run/docker.sock, the host.
Work the postures from the bottom of that table upwards. First check whether the runner already supports the namespace sandbox; if it does, the image needs nothing beyond the non-root USER line it already has. If the runtime’s seccomp filter is the blocker, supply a profile that permits the clone and unshare flags Chromium needs — that is a far narrower grant than a capability. Reach for SYS_ADMIN only when you cannot change the seccomp profile, and treat --no-sandbox as an incident to be tracked, gated behind the A11Y_UNSAFE_NO_SANDBOX environment variable in the scan script above so it cannot be enabled by accident.
# 1. Does the host already allow the namespace sandbox? 1 means yes.
sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || echo "not present (ok on 5.x)"
# 2. Prove Chromium launches sandboxed, as the non-root user, with no extra caps.
docker run --rm --shm-size=1g --init --user 10001 \
--entrypoint node a11y-scan:ci \
-e "const {chromium}=require('playwright');(async()=>{ \
const b=await chromium.launch(); \
const p=await b.newPage(); await p.goto('about:blank'); \
console.log('sandboxed launch ok', b.version()); await b.close();})()"
# 3. Only if step 2 fails on seccomp: narrow grant, not a capability.
# Save Playwright's published Chromium seccomp profile as chromium-seccomp.json.
docker run --rm --shm-size=1g --init --user 10001 \
--security-opt seccomp=./chromium-seccomp.json a11y-scan:ci
4. Make Fonts, Locale and Timezone Deterministic
Install the fonts the design actually uses, then prove the container resolves them. fonts-liberation2 covers the metric-compatible Arial, Times and Courier substitutes that most stylesheets fall back to; fonts-noto-core and fonts-noto-cjk stop translated builds rendering as .notdef boxes. If the product ships a licensed webfont, copy the font files into the image and register them with fontconfig rather than relying on the page to fetch them at scan time — a scan that downloads its fonts is a scan that behaves differently when the network is slow.
fc-match is the single most useful diagnostic in this whole guide, because it answers “what will the browser actually use” in one line, without launching a browser.
# What does the container resolve for the families the stylesheet asks for?
docker run --rm --entrypoint fc-match a11y-scan:ci "Inter"
# DejaVuSans.ttf: "DejaVu Sans" "Book" <-- substituted: metrics WILL differ
docker run --rm --entrypoint fc-match a11y-scan:ci "Noto Sans CJK JP"
# NotoSansCJK-Regular.ttc: "Noto Sans CJK JP" "Regular" <-- exact match
# Count the faces in the image; a sudden drop after a base-image bump is a
# reproducibility regression, not a cosmetic one.
docker run --rm --entrypoint fc-list a11y-scan:ci | wc -l
# Locale and timezone are inputs to Intl output and to native control names.
docker run --rm --entrypoint sh a11y-scan:ci -c 'locale; date'
Locale is not only a font question. Chromium’s UI language decides the accessible name of every control the browser renders itself: the button inside <input type="file">, the default marker text of <summary>, the parts of a date picker, the placeholder of <input type="color">. A test suite that asserts on those names — including a snapshot of the accessibility tree — passes under LANG=en_GB.UTF-8 and fails under LANG=C, which is the default in a bare container. Setting locale and timezoneId on the Playwright context, as the scan script does, pins the page-level behaviour; setting LANG, LC_ALL and TZ in the image pins everything below it, including any server-side rendering that runs in the same container. The locale dimension of a scan matrix is a subject of its own, handled in the internationalization and localization testing guide.
5. Size Shared Memory and Reap Processes
Chromium moves rendered frames and IPC payloads through /dev/shm. Docker mounts that as a 64 MB tmpfs by default, and a real application page — a dashboard with several canvases, a long product list with images, or simply four pages open in one browser instance — exhausts it. The symptom is never “out of shared memory”. It is a renderer crash reported by the driver as Target page, context or browser has been closed, arriving on a different page each run, which is exactly the profile of a flaky test rather than a configuration bug.
There are three fixes and they are not equivalent. --shm-size=1g (shm_size: "1gb" in compose) enlarges the tmpfs and is the right answer, because Chromium keeps using shared memory and stays fast. --disable-dev-shm-usage tells Chromium to write those temporary files under /tmp instead; use it when you cannot control the run flags — a managed container job that does not expose shm_size — and accept that it moves the traffic onto the container’s writable layer, which is slower and can fill the disk on a long crawl. --ipc=host shares the host’s IPC namespace and sidesteps the limit entirely, at the cost of a real isolation boundary between the container and its host; it is a reasonable choice on an ephemeral runner and a poor one on a shared self-hosted machine.
# Fragment for docker-compose.a11y.yml: pick exactly one of the three.
services:
scanner:
shm_size: "1gb" # preferred: keep shared memory, just make it big enough
init: true # PID 1 that reaps Chromium's orphaned children
# Fallback for platforms that will not let you size /dev/shm. Add the flag
# to chromium.launch() args instead of setting it here:
# '--disable-dev-shm-usage'
# Last resort on ephemeral runners only; removes container/host IPC isolation:
# ipc: host
ulimits:
# Chromium spawns a process per renderer plus zygotes and GPU helpers.
nproc: 4096
init: true matters more than it looks. A crashed or force-killed Chromium leaves child processes whose parent is gone; with node as PID 1 nothing reaps them, they accumulate as zombies, and the container either hangs at shutdown or hits the process limit on the fourth page. Docker’s tiny init reaps them for free. Pair it with a finally block around browser.close() in the scan script so an assertion failure still tears the browser down.
Pipeline Integration
The container is one step and its exit code is the whole gate. Build the image with a registry-backed layer cache, bring the pair up with --exit-code-from scanner so compose propagates the scanner’s status, and always upload the report even when the job failed — a failing gate with no artifact is a gate nobody can act on. Create the host report directory before the run and pass the invoking user’s uid so the JSON is not root-owned, which otherwise breaks both artifact upload on some runners and the workspace cleanup step.
name: a11y-container
on:
pull_request:
paths:
- 'src/**'
- 'Dockerfile.scan'
- 'Dockerfile.app'
- 'docker-compose.a11y.yml'
- 'scripts/scan.mjs'
- '.github/workflows/a11y-container.yml'
concurrency:
group: a11y-container-${{ github.head_ref }}
cancel-in-progress: true
jobs:
containerised-scan:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build the scan image with a shared layer cache
run: |
docker buildx build --file Dockerfile.scan \
--tag a11y-scan:${{ github.sha }} \
--cache-from type=gha --cache-to type=gha,mode=max \
--load .
- name: Record the image digest next to the report
run: |
mkdir -p reports
docker image inspect a11y-scan:${{ github.sha }} \
--format '{{.Id}}' > reports/image-id.txt
- name: Serve the app and scan it over the container network
run: |
# SCAN_UID is read by the compose file's user: key, so reports/ stays
# writable by every later step in this job.
SCAN_UID="$(id -u):$(id -g)" \
docker compose -f docker-compose.a11y.yml up \
--abort-on-container-exit --exit-code-from scanner
- name: Summarise the violations for the run page
if: always()
run: |
jq -r '"| path | rule | impact | nodes |", "|---|---|---|---|",
(.results[] | .path as $p | .violations[]
| "| \($p) | \(.id) | \(.impact) | \(.nodes) |")' \
reports/a11y.json >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-container-report
path: reports/
retention-days: 14
- name: Tear down the network and volumes
if: always()
run: docker compose -f docker-compose.a11y.yml down --volumes
The report’s engine block is what turns this from a pass/fail signal into a diagnosable one. When a violation appears, compare the engine.browser and engine.axeCore values against the previous green run before reading a single rule ID: identical engine plus new violations means the application changed, and different engine plus new violations means the image moved and the finding needs re-confirming under the old digest. That distinction is the difference between filing a bug and reverting a Dependabot pull request. Whether a new finding blocks the merge immediately or lands as an annotation for a soak period is a policy decision made in auto-fail vs warning workflows, and the JSON shape above is deliberately flat so it can be appended to the trend store described in reporting, dashboards and violation tracking.
For a repository with many applications, build the scan image once and fan the paths out across parallel jobs rather than rebuilding per package; the sharding mechanics and report merging are covered in monorepo parallel test sharding. The scanner options themselves — tags, exclusions, rule-level overrides — belong in the axe-core configuration and setup guide; this image only guarantees that whatever configuration you choose is evaluated against a fixed render.
Troubleshooting and Flaky-Test Mitigation
No usable sandbox! Update your kernel or see … — the runner cannot host Chromium’s namespace sandbox. Check sysctl kernel.unprivileged_userns_clone, then try a Chromium seccomp profile, then --cap-add=SYS_ADMIN, and only then the guarded --no-sandbox escape hatch. Do not silently add the flag in the Dockerfile, where nobody reviewing the workflow will ever see it.
Target page, context or browser has been closed, on a different page each run — /dev/shm exhaustion, almost always. Set shm_size: "1gb" first and re-run the job five times before you believe the fix; a 64 MB tmpfs fails probabilistically depending on which page rendered the most pixels.
connect ECONNREFUSED 127.0.0.1:8080 — the scanner is talking to its own loopback. Use the compose service name. If the app is genuinely on the runner host rather than in a container, add --add-host=host.docker.internal:host-gateway, because that hostname does not exist on Linux by default.
The healthcheck never turns healthy — the app is bound to 127.0.0.1 inside its own container, so nothing outside the container can reach it and the healthcheck’s own curl may still succeed. Bind 0.0.0.0. This is the most common cause of a scan job that times out after twenty minutes with no useful log.
Contrast violations that appear on roughly one run in five — a webfont is racing the scan. document.fonts.ready plus the requestfailed guard in the scan script closes it: if the font never arrived, the run fails loudly with a font error instead of quietly reporting contrast findings measured against fallback metrics.
Violations that only appear on the first page of a multi-path run — a stale browser context carrying service-worker or cache state between paths. Create a fresh newPage() per path as the script does, and use a fresh newContext() per path when the app persists anything in localStorage that changes the rendered state.
Root-owned files in reports/ — the container wrote as uid 0. Set user: in the compose service or --user "$(id -u):$(id -g)" on docker run, and pre-create the directory on the host so the mount does not inherit root ownership from the daemon.
A job that hangs after the last assertion — an un-reaped Chromium tree. init: true plus a try/finally around the scan loop that always calls browser.close() resolves it. If the hang persists, check ulimits.nproc; a process-limit wall looks exactly like a hang.
Scans that pass in the container and fail on a laptop — usually the laptop, not the container. Run the same image locally with docker run --rm --shm-size=1g --init -v "$PWD/reports:/out" a11y-scan:ci against a locally served build before you start debugging the pipeline.
Common Pitfalls
- Floating base tags.
node:20andnode:20-slimadvance without notice, taking glibc, fontconfig and font packages with them. Pin the patch version, then the digest. apt-get install chromium. The distribution’s browser is unpinnable in practice and unrelated to the revision your driver expects. Let the pinned driver fetch its own revision.COPY . .beforenpm ci. Every source edit invalidates the install layer and the browser-download layer, adding minutes per run for nothing.- Blanket
--no-sandboxbaked into the image. It removes the isolation boundary for every future scan, including the one someone points at a third-party URL next quarter. - Leaving
/dev/shmat 64 MB. The resulting crashes get logged as flaky accessibility tests and eventually get the gate marked non-required. - No fonts beyond the base image’s. A scan on a
-slimimage with no font packages measures contrast on substituted metrics and reports CJK content as boxes. LANGleft unset. The container defaults toC, native control names change, and accessibility-tree snapshots fail for reasons unrelated to the code under test.- Publishing the app’s port to the host. It is unnecessary on a shared bridge network and it makes the scan depend on the runner having that port free.
- Discarding the report on failure. Without
if: always()on the upload step, the only runs whose artifacts you keep are the ones you did not need. - Not recording the image digest. Without it, the next “new violation” argument cannot be settled, and the whole reproducibility exercise is wasted.
FAQ
Is a pinned container enough, or does the browser version still need updating? It needs updating, deliberately and on its own commit. Pinning is not freezing: an ancient Chromium diverges from what real users run, and axe-core’s newer rules assume newer browser behaviour. Bump the driver version in its own pull request with no application changes, so the resulting diff in the violation list is unambiguous evidence of what the browser changed, then triage that diff rather than discovering it mixed into a feature branch.
Should the app under test run in the same container as the scanner?
Keep them separate. One process per container makes the healthcheck meaningful, lets the app image keep its dev-server dependencies out of the scan image, and means a crashed browser cannot take the server down mid-scan. The exception is a purely static build, where serving dist/ from the scanner container with a small static file server removes a whole service and a whole network hop; that pattern is used by the Lighthouse variant in running Lighthouse CI in a Docker-based pipeline.
Does --no-sandbox change the scan results?
Not measurably. The sandbox is a privilege boundary, not a rendering feature, so contrast, accessible names and layout come out the same. The reason to care is that a renderer bug plus untrusted page content plus no sandbox means arbitrary code running with the container’s credentials, and a CI container’s credentials are usually worth more than the container.
How much slower is a containerised scan than one on the runner’s own browser? On a warm layer cache the difference is the image pull, which is typically fifteen to thirty seconds for a slim Node base plus a Chromium layer, against zero for a pre-installed browser. Against that, a pinned image removes an entire class of investigation, and the pull cost is recovered the first time a violation list changes and you can prove in one command that the engine did not.
Can the same image scan a deployed preview environment instead of a compose service?
Yes, and it should — that is why the origin is an environment variable. Point SCAN_ORIGIN at the preview URL and drop the app service entirely. Two things change: the network is no longer isolated, so a slow or rate-limited preview host becomes a source of timeouts, and the content is only as trustworthy as the preview build, which moves the sandbox question from theoretical to real if that build came from a fork.
Related
- CI/CD Integration & Automated Quality Gating — the parent section: thresholds, gating policy and where this exit code lands.
- Caching axe-core Browser Binaries in CI Containers — keeping the browser download out of the critical path of every run.
- Running Lighthouse CI in a Docker-Based Pipeline — the same discipline applied to
lhci autorunand a score threshold. - Playwright Accessibility Plugin Integration — the runner this image wraps, and its waiting and fixture patterns.
- Auto-Fail vs Warning Workflows — turning a reproducible exit code into a merge decision the team accepts.