Running Lighthouse CI in a Docker-Based Pipeline

This guide is part of Docker-Based Pipeline Execution, and it walks through running Lighthouse CI (lhci) inside a container so the accessibility category score is computed against a pinned Chrome rather than whatever browser the CI runner happens to ship. Lighthouse’s accessibility audits map to a defined subset of WCAG 2.2 success criteria, and because the score is partly derived from rendered output, an unpinned browser makes the number drift. Containerizing fixes the browser, the runtime, and the audit set in one image.

Baseline controls this page establishes:

  • A Dockerfile that installs Chrome’s runtime dependencies and pins the browser
  • A lighthouserc.js that asserts a minimum accessibility category score
  • A CI step that runs lhci autorun and uploads the HTML/JSON artifacts
  • A correct, narrowly scoped --no-sandbox posture for unprivileged containers

This complements your broader Lighthouse CI baseline configuration; here we focus only on making it run reproducibly in Docker.

lhci autorun phases inside a container The container starts headless Chrome, lhci collects runs, asserts the accessibility score against a threshold, then uploads artifacts or fails the gate. headless Chrome (pinned) collect N runs assert a11y score ≥ 0.95? upload artifacts fail gate exit 1
Inside the container, lhci collects runs against pinned Chrome, asserts the accessibility score, then uploads artifacts or fails the gate.

Root Cause / Context

The default lhci invocation launches whatever Chrome it can find on PATH. On a hosted CI runner that browser updates on the provider’s schedule, so a Lighthouse accessibility score of 0.97 one week can become 0.94 the next with zero code changes — purely a rendering or audit-set difference. Because Lighthouse derives part of the accessibility category from the live render (focusability, color contrast of computed styles), the browser version is a real input to the score, not an incidental detail.

The second failure mode is the sandbox. Chrome’s setuid sandbox requires kernel privileges that unprivileged CI containers do not grant, so lhci crashes on launch with a namespace error. The fix is to pass Chrome flags through lighthouserc.js, scoping --no-sandbox to the audited Chrome process only — not disabling sandboxing across the whole image.

Configuration

The Dockerfile pins Chrome’s dependencies and bundles @lhci/cli. The lighthouserc.js collects three runs (median smooths flake), asserts a minimum accessibility score, and stores artifacts to a temp directory the CI job copies out.

# syntax=docker/dockerfile:1.7
FROM node:20.11.1-bookworm-slim AS lhci

ENV DEBIAN_FRONTEND=noninteractive
# Chrome runtime libraries; without these headless Chrome cannot render.
RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates fonts-liberation libnss3 libatk1.0-0 \
      libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
      libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libasound2 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci   # installs @lhci/cli and its pinned Chromium via puppeteer

COPY . .
RUN npm run build

# Run as non-root; --no-sandbox is scoped to Chrome in lighthouserc.js below.
RUN useradd -m lhciuser && chown -R lhciuser /app
USER lhciuser

CMD ["npx", "lhci", "autorun"]
// lighthouserc.js — collected, asserted, and uploaded by `lhci autorun`.
module.exports = {
  ci: {
    collect: {
      // lhci builds + serves the static output, then audits this URL.
      staticDistDir: './dist',
      numberOfRuns: 3, // median of 3 runs reduces score flake
      settings: {
        onlyCategories: ['accessibility'], // skip perf/SEO for a fast gate
        // Flags scoped to the audited Chrome process only, not the image.
        chromeFlags: '--no-sandbox --headless=new --disable-gpu',
      },
    },
    assert: {
      assertions: {
        // Fail the run if the WCAG-aligned a11y category drops below 0.95.
        'categories:accessibility': ['error', { minScore: 0.95 }],
        // Treat specific audits as hard blockers regardless of category math.
        'color-contrast': 'error',    // WCAG 2.2 SC 1.4.3
        'label': 'error',             // WCAG 2.2 SC 1.3.1 / 4.1.2
        'image-alt': 'error',         // WCAG 2.2 SC 1.1.1
      },
    },
    upload: {
      target: 'filesystem',
      outputDir: './lhci-report', // copied out as a CI artifact
    },
  },
};

The CI step builds the image and runs it, mounting the report directory back to the host so the results survive the container.

- name: Build lhci image
  run: DOCKER_BUILDKIT=1 docker build -t lhci-a11y:ci .
- name: Run Lighthouse CI accessibility gate
  run: |
    docker run --rm \
      -v "$PWD/lhci-report:/app/lhci-report" \
      lhci-a11y:ci   # non-zero exit when an assertion fails
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: lighthouse-a11y-report
    path: lhci-report/

Validation

Confirm the gate behaves correctly by inspecting the assertion result emitted by lhci. A passing run reports 0 of N assertions failed; a regression below the threshold exits non-zero and lists the offending category.

# Run locally against the same image to verify before pushing.
docker run --rm -v "$PWD/lhci-report:/app/lhci-report" lhci-a11y:ci; echo "exit=$?"

# Expected on a passing build:
#   All results processed!
#   Done running Lighthouse!
#   exit=0
#
# Expected when accessibility < 0.95:
#   [FAIL] categories:accessibility failure for minScore assertion
#          expected: >=0.95   found: 0.91
#   exit=1

You can also assert against the stored JSON directly to prove the score was captured:

# Pull the median run's accessibility score out of the report manifest.
jq '.[] | select(.isRepresentativeRun==true) | .summary.accessibility' \
  lhci-report/manifest.json   # prints e.g. 0.97

Edge Cases & Conditional Guards

  • Async content not ready: If the page hydrates after Lighthouse’s load signal, add a settings.maxWaitForLoad bump or a route that signals readiness, so late-rendered controls are audited.
  • --no-sandbox only when required: If your runner supports user namespaces, drop the flag — sandboxing is a real defense. Keep it strictly inside chromeFlags, never as a container-wide capability.
  • Authenticated pages: Lighthouse audits the served URL with no session; use collect.url against a pre-seeded preview rather than staticDistDir when the page needs auth state.

Pipeline Impact

Because lhci autorun returns a non-zero exit code when any assertion fails, the container step is a self-contained gate — no extra parsing script is needed for the score threshold. The uploaded lhci-report/ directory gives reviewers the full HTML report as a downloadable artifact, and the manifest.json is machine-readable for trend dashboards. To convert a sub-threshold result into a soft warning during a remediation period rather than a hard block, route it through Auto-Fail vs Warning Workflows.

Common Pitfalls

  • Single run, noisy score: One Lighthouse pass is flaky. Use numberOfRuns: 3 and let lhci take the median.
  • Disabling the sandbox globally: Setting --privileged or dropping seccomp to “fix” the crash is overkill and unsafe. Scope --no-sandbox to chromeFlags.
  • Forgetting fonts: Without fonts-liberation, text renders with fallback metrics and contrast/reflow audits drift.
  • Not mounting the report out: Artifacts written inside --rm containers vanish. Always bind-mount the output dir.
  • Asserting only the category score: A 0.95 category can still hide a hard failure; pin individual audits like color-contrast to error.

FAQ

Why does lhci crash with a “No usable sandbox” error in my container? Unprivileged containers do not grant the kernel privileges Chrome’s setuid sandbox needs. Add --no-sandbox to chromeFlags in lighthouserc.js so it applies only to the audited Chrome process. If your runner allows user namespaces, prefer enabling those and removing the flag.

Does the Lighthouse accessibility score equal WCAG conformance? No. The accessibility category covers a defined subset of automatable checks that map to criteria like WCAG 2.2 SC 1.1.1, 1.3.1, 1.4.3, and 4.1.2. A perfect score does not certify full conformance; manual testing is still required for the criteria automation cannot evaluate.

Should I gate on the category score or individual audits? Both. The category minScore catches broad regressions, while pinning specific audits (color-contrast, label, image-alt) to error ensures a critical failure blocks the merge even if the aggregate score stays above the threshold.