Running lhci autorun Inside a Container
This guide is part of Docker-Based Pipeline Execution, and it is about one command in one environment: lhci autorun executing inside a container on a shared CI runner. Lighthouse behaves differently there than it does on a laptop in three specific ways — it cannot launch Chrome without help, its default throttling turns runner CPU contention into score variance, and everything it writes disappears when the container is removed. Each has a precise fix, and none of them is “retry the job”.
Root Cause
Chrome will not start in a default container job. Its multi-process sandbox needs kernel facilities the runtime does not necessarily grant, /dev/shm is a 64 MB tmpfs that a real page exhausts, and there is no GPU. lhci does not pass any of those flags for you; it forwards what you put in ci.collect.settings.chromeFlags, and if that is empty you get No usable sandbox or, worse, an intermittent renderer crash reported as a Lighthouse run failure. The browser also has to be pinned, because the accessibility category is computed from a live render: contrast comes from composited colours and accessible names come from Chrome’s own name computation, so an unpinned browser makes the number drift on its own schedule.
The throttling problem is subtler and it is the one that gets accessibility gates disabled. Lighthouse’s default throttlingMethod is simulate, which applies a 4× CPU slowdown multiplier while it gathers, then models network latency afterwards. On a two- or four-vCPU runner that is already executing other jobs, that artificial 4× compounds with real contention. Accessibility audits do not measure time, so it is tempting to assume they are immune — but they do not run against the live page. They run against the DOM and accessibility-tree artifacts Lighthouse captured at the end of the gathering pass. When the page has not finished rendering by then, that snapshot contains fewer nodes: a table that had not hydrated, a dialog that had not mounted, a list rendered empty. Audits such as color-contrast, label and aria-allowed-attr therefore see a different node set, and audits with nothing applicable to test drop to notApplicable and leave the weighted average entirely. The score moves because the denominator moved.
The third issue is trivial and catches everyone once. lhci writes its collected runs to .lighthouseci/ inside the working directory, and docker run --rm deletes that directory along with the container the moment the process exits. A failing assertion then produces an exit code with no report to explain it, which is the least useful possible outcome of a gate.
Configuration
Pin Chrome by version number rather than letting lhci find something on PATH, and tell it exactly where that binary is with CHROME_PATH. Installing a specific Chrome for Testing build gives a version string you can read in a bug report, which matters because the accessibility score is partly a property of the browser. The library list below is what that build links against on a -slim Debian base.
# syntax=docker/dockerfile:1.7
# Dockerfile.lhci — pinned Chrome for Testing plus @lhci/cli, nothing else.
FROM node:20.18.1-bookworm-slim AS lhci
ARG CHROME_VERSION=130.0.6723.31
ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_GB.UTF-8 \
TZ=UTC
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates fontconfig fonts-liberation2 fonts-noto-core \
libasound2 libatk-bridge2.0-0 libatk1.0-0 libcairo2 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 \
libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
# Exact build, its own layer, no floating tag anywhere in the chain.
RUN npx --yes @puppeteer/browsers install "chrome@${CHROME_VERSION}" --path /chrome
ENV CHROME_PATH=/chrome/chrome/linux-${CHROME_VERSION}/chrome-linux64/chrome
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # includes @lhci/cli
# Build the static output that lhci will serve itself. No dev server anywhere.
COPY . .
RUN npm run build
RUN useradd --create-home --uid 10001 lhci \
&& mkdir -p /out && chown lhci:lhci /out /app
USER lhci
CMD ["npx", "lhci", "autorun"]
The configuration file carries the three container fixes. chromeFlags scopes the sandbox and shared-memory workarounds to the audited Chrome process rather than weakening the container; throttlingMethod: 'provided' removes the artificial CPU slowdown that turns runner contention into score variance; and outputDir under the mounted path is what lets the report outlive the container.
// lighthouserc.cjs — .cjs so it loads even when package.json says "type":"module".
module.exports = {
ci: {
collect: {
// lhci serves ./dist itself and audits every HTML file it finds there.
staticDistDir: './dist',
// Five runs so a single degraded gather cannot decide the gate.
numberOfRuns: 5,
settings: {
onlyCategories: ['accessibility'], // no perf traces: faster and stabler
// Flags for the audited Chrome only; the container stays unmodified.
chromeFlags: [
'--headless=new',
'--no-sandbox', // see the note below before keeping this
'--disable-dev-shm-usage', // /dev/shm is 64 MB unless you raise it
'--disable-gpu',
'--font-render-hinting=none',
].join(' '),
// No artificial 4x CPU slowdown: the snapshot is taken after the page
// has actually rendered, even on a contended runner.
throttlingMethod: 'provided',
maxWaitForLoad: 45000, // headroom for a slow shared runner
locale: 'en-GB', // fixes the names of native controls
},
},
assert: {
// Assert against the median run explicitly rather than the default.
aggregationMethod: 'median-run',
assertions: {
'categories:accessibility': ['error', { minScore: 0.95 }],
// Individual audits pinned to error: a 0.95 average can still hide one.
'color-contrast': 'error', // WCAG 2.2 SC 1.4.3 Contrast (Minimum)
'label': 'error', // WCAG 2.2 SC 4.1.2 Name, Role, Value
'image-alt': 'error', // WCAG 2.2 SC 1.1.1 Non-text Content
'target-size': 'warn', // WCAG 2.2 SC 2.5.8, newer and noisier
},
},
upload: {
target: 'filesystem',
// The bind-mounted directory, so the report survives docker run --rm.
outputDir: '/out',
reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%.report.%%EXTENSION%%',
},
},
};
--no-sandbox is in that list because most runners need it, not because it is safe. It is defensible for a staticDistDir built from your own repository on a trusted branch; it is not defensible for a preview built from a fork’s pull request, where an attacker chooses the JavaScript the renderer executes. If the runner supports unprivileged user namespaces, delete the flag and confirm Chrome still launches — the sandbox postures and how to test them are set out in the Docker-based pipeline execution guide.
Validation
Run the image the way CI will, then check three things: the exit code, the pinned Chrome version that actually gathered, and the presence of the report on the host. Setting --user to the invoking account is what keeps the written files usable by later steps.
# Build and run exactly as the pipeline does.
docker build --file Dockerfile.lhci --tag lhci-a11y:ci .
mkdir -p lhci-report
docker run --rm \
--shm-size=1g \
--init \
--user "$(id -u):$(id -g)" \
-v "$PWD/lhci-report:/out" \
lhci-a11y:ci; echo "exit=$?"
# Passing run:
# Automatically determined ./dist as `staticDistDir`.
# Running Lighthouse 5 time(s) on http://localhost:39121/index.html
# All results processed!
# exit=0
#
# Failing assertion:
# [FAIL] categories:accessibility failure for minScore assertion
# expected: >=0.95 found: 0.91
# exit=1
# Which Chrome gathered, and was throttling really disabled?
jq -r '.environment.hostUserAgent, .configSettings.throttlingMethod' \
lhci-report/*index.html*.report.json | head -2
Then assert on the report itself rather than only on the exit code, because the interesting question during a rollout is how much headroom the gate has. The script below reads every run in the output directory, prints the spread, and fails if the runs disagree by more than two points — a spread that wide means the container is still starved rather than the page being broken.
// scripts/check-lhci-spread.mjs — usage: node scripts/check-lhci-spread.mjs /out
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const dir = process.argv[2] ?? '/out';
const scores = readdirSync(dir)
.filter((f) => f.endsWith('.report.json'))
.map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
.map((lhr) => lhr.categories.accessibility.score);
if (scores.length === 0) throw new Error(`no lhr JSON found in ${dir}`);
const sorted = [...scores].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const spread = sorted.at(-1) - sorted[0];
console.log(`runs=${scores.length} median=${median.toFixed(2)} ` +
`spread=${spread.toFixed(2)}`);
// A wide spread means unstable gathering, not an unstable page.
if (spread > 0.02) {
console.error('spread above 0.02: check throttlingMethod, shm size and CPU');
process.exit(1);
}
Edge Cases and Conditional Guards
- Content that renders after the load signal.
staticDistDiraudits a static file, so a single-page application that fetches its real content will be snapshotted nearly empty and score suspiciously well. RaisemaxWaitForLoad, and where the content depends on a request, audit a pre-rendered route or switch tocollect.urlagainst a seeded preview so the audited DOM is the one users get. - Authenticated pages. Lighthouse arrives with no session, so a protected route redirects and the audit describes the login page. Use
collect.urlwith a preview deployment that accepts a scoped token, or audit a fixture route that renders the same components with seeded data rather than trying to script a login insideautorun. - A runner too small for five runs.
numberOfRuns: 5on a two-vCPU runner can cost four minutes even withonlyCategories: ['accessibility']. Drop to three runs and keepthrottlingMethod: 'provided'; the throttling setting removes far more variance than the extra runs do, and a spread check makes it obvious if three is not enough.
Pipeline Impact
lhci autorun exits non-zero when any assertion fails, so the container is a complete gate with no wrapper script — the exit code of docker run is the exit code of the job step. Keep the artifact upload behind if: always() so the HTML report is attached to exactly the runs that failed, and treat the manifest.json as the machine-readable half: it names the representative run, which is the file worth linking from a pull-request comment.
- name: Lighthouse accessibility gate
run: |
mkdir -p lhci-report
docker run --rm --shm-size=1g --init \
--user "$(id -u):$(id -g)" \
-v "$PWD/lhci-report:/out" \
lhci-a11y:ci
- name: Fail on unstable gathering, not just on a low score
if: always()
run: node scripts/check-lhci-spread.mjs "$PWD/lhci-report"
- uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-accessibility-report
path: lhci-report/
retention-days: 30
A Lighthouse score is a weighted average, so it is a good trend signal and a poor debugging tool: one failing audit on many nodes and one failing audit on a single node can produce the same number. Pair the category threshold with the pinned individual audits above, and when a score moves without an obvious cause, compare rule-level output rather than scores — the mapping between the two is worked through in Lighthouse accessibility score vs axe violation counts. The threshold itself should be chosen deliberately rather than copied, using the baseline method in setting up Lighthouse CI thresholds for WCAG 2.2 AA, and a brand-new threshold should spend a fortnight reporting rather than blocking, following auto-fail vs warning workflows.
Common Pitfalls
- A single run. One gather decides the gate and any hiccup fails the build. Use three to five runs with
aggregationMethodset explicitly so you know which run is being asserted against. - Leaving
throttlingMethodat its default while gating on accessibility. The 4× CPU slowdown exists to model a slow phone for performance metrics; on a contended runner it only makes the audited snapshot incomplete. - Using
providedwhile also gating on performance. With throttling off, the performance metrics describe the runner rather than a user, and comparing them across runs is meaningless. Split the two gates into separate configurations. - Auditing a dev server. The hot-reload client and error-overlay root are real DOM nodes with real accessibility properties, and they are not in production.
- Forgetting the bind mount or
outputDir. Either one alone still loses the report: the mount withoutoutputDirleaves lhci writing to.lighthouseci, andoutputDirwithout the mount writes into a filesystem that is about to be deleted. - Running as root so the report is root-owned. Later workspace steps then fail to clean up, and some artifact actions cannot read the files. Pass
--user. - Asserting only the category score. A 0.95 average tolerates a genuine
color-contrastfailure. Pin the audits that map to the criteria you have committed to.
FAQ
Why does the accessibility score move when only performance is being throttled?
Because accessibility audits run against the DOM and accessibility-tree artifacts captured at the end of the gathering pass, not against a live page. Slower gathering means a less complete snapshot, so audits see fewer nodes and some become notApplicable and drop out of the weighted average entirely. Setting throttlingMethod: 'provided' lets the page reach its settled state before the snapshot, which is why the spread collapses.
Should the container run lhci autorun or the separate collect, assert and upload commands?
autorun for a gate, because it sequences the three phases and returns the assertion result as the process exit code with nothing to wire together. Split them when you need work between phases — collecting once and then asserting the same runs against two different thresholds, or uploading to a server and a filesystem target in one job. The container and flag setup is identical either way.
Does this replace an axe-core scan? No, it complements it. Lighthouse runs a subset of the axe rule set and folds the results into one weighted number, which makes it a good trend and threshold signal and a weak diagnostic. A rule-level axe run gives per-node output that a developer can act on, which is why many pipelines keep both, and why comparing the two is a rule-ID exercise rather than a score-versus-count one.
Related
- Docker-Based Pipeline Execution — the parent guide: pinning the browser, fonts, locale and shared memory this container depends on.
- Caching axe-core Browser Binaries in CI Containers — keeping the pinned Chrome download out of every run.
- CI/CD Integration & Automated Quality Gating — where this exit code becomes a required status check and a threshold policy.