How to run Lighthouse in GitLab CI
Here's a GitLab CI job that audits your build and puts the report in the merge request:
# .gitlab-ci.yml
stages: [test]
lighthouse:
stage: test
image: registry.gitlab.com/gitlab-ci-utils/lighthouse:8.13.3
script:
- serve ./dist/ > /dev/null 2>&1 & wait-on http://localhost:3000/
- lighthouse http://localhost:3000/ --output=json,html --output-path=./lighthouse
artifacts:
when: always
expose_as: 'Lighthouse report'
paths:
- lighthouse.report.html
- lighthouse.report.json
That's the five-minute part, and most guides on this query stop there. Two things then bite. The command you type decides which Lighthouse version grades your page. And your GitLab tier decides whether the numbers ever reach a merge request.
What's in the image
registry.gitlab.com/gitlab-ci-utils/lighthouse ships Chromium, the lighthouse
CLI, @lhci/cli, serve and wait-on. So a job needs no install step. I pulled
:latest on 2 September 2026 and asked it what it contains:
| Component | Version |
|---|---|
| Chromium | 151.0.7922.173 |
lighthouse |
13.4.1 |
lhci |
0.15.1 |
| Node.js | 24.20.0 |
| Base | Debian 12 bookworm |
Pin the tag. :latest is rebuilt when its dependencies move. All four
August 2026 releases bumped Chromium, and three of them bumped Node. A moving image is a
moving baseline, and a score change then tells you nothing about your code, only
about the image that graded it. The version I pulled matches release 8.13.3,
dated 30 August 2026.
Two details in the job above are worth knowing. --output-path=./lighthouse is a
prefix, not a filename. With more than one output type Lighthouse appends
.report.<ext>, so you get lighthouse.report.json and lighthouse.report.html.
With a single output type it uses the path you gave, verbatim. And when: always
keeps the report when the job fails, which is when you want to read it.
Do you actually need --no-sandbox?
Probably not. It's the flag this subject cargo-cults hardest, and Lighthouse CI's own GitLab example passes it:
lhci autorun --collect.settings.chromeFlags="--no-sandbox"
The usual reason for that flag is Chrome as root. The
gitlab-ci-utils/lighthouse image sidesteps it by running as the non-root user
lhuser instead — I confirmed uid=997(lhuser) in the container. Its README says
the seccomp policy on GitLab.com's shared Linux runners lets the sandbox work.
So try the job without the flag first. On a self-managed runner the answer depends on your seccomp profile and your container settings, and you may still need it. Turning off the Chrome sandbox is a security decision about a browser that visits a URL you chose. Make it on purpose, not by copying a snippet.
The same job with Lighthouse CI
The lighthouse CLI runs a page once and reports. lhci autorun collects several
runs, asserts the aggregated result against the thresholds in your config file, and fails
the job when one threshold misses. Use it for a gate. Use the plain CLI for a document.
lighthouse:
stage: test
image: registry.gitlab.com/gitlab-ci-utils/lighthouse:8.13.3
script:
- lhci autorun
artifacts:
when: always
paths:
- .lighthouseci/
// lighthouserc.json
{
"ci": {
"collect": { "staticDistDir": "./dist", "numberOfRuns": 5 },
"assert": {
"assertions": {
"total-byte-weight": ["error", { "maxNumericValue": 1600000 }],
"unminified-javascript": "error",
"uses-text-compression": "error"
}
},
"upload": { "target": "filesystem", "outputDir": "./.lighthouseci" }
}
}
numberOfRuns defaults to 3. Raise it to 5. A CI runner is a shared machine, so
one run samples the machine as much as your page.
Assert on bytes and on missing compression, not on the performance score. The score moves with the runner. Total Blocking TimeHow long the main thread was busy and couldn’t respond to taps or clicks. moves with the runner too, and it carries the largest weight in the composite. Byte counts don't. That split is argued in how to run Lighthouse in CI. It also covers the aggregation default, which decides which of your five runs gets compared.
If you'd rather not maintain the YAML, the to-be-continuous project publishes a
Lighthouse component you can include in one block. It defaults to the
cypress/browsers image and drives lhci for you.
The image gives you two Lighthouse versions
This is the finding I didn't expect, and the guides I read on this query don't mention it.
In the image I pulled, lighthouse --version prints 13.4.1, which is the current
Lighthouse release. lhci --version prints 0.15.1. Lighthouse CI carries its own
nested copy of Lighthouse, and that copy is 12.6.1:
/lighthouse/node_modules/lighthouse 13.4.1
/lighthouse/node_modules/@lhci/cli/node_modules/lighthouse 12.6.1
The npm registry agrees: @lhci/cli 0.15.1 pins lighthouse at exactly 12.6.1.
So one image, one job, one pipeline — and a whole major version between the two
commands. Major releases move the scoring: Lighthouse 13.0.0 adjusted the
accessibility weights and dropped several performance audits, which it replaced with
performance insights. So a lighthouse number and an lhci number don't compare,
even from the same commit on the same runner. Pick one command and stay on it. Then record the
version next to every score you keep. The image moves under you the moment you
unpin the tag.
You can check this yourself in one line:
docker run --rm --entrypoint lighthouse \
registry.gitlab.com/gitlab-ci-utils/lighthouse:8.13.3 --version
How do I see the results in a merge request?
Two mechanisms, and the useful one for most people is the cheaper one.
| Mechanism | Tier | What you get |
|---|---|---|
artifacts:expose_as |
any | a link to the report from the merge request |
artifacts:reports:metrics |
Premium, Ultimate | source-vs-target comparison in the widget |
expose_as is one line, and it's in the first job above. One rule shapes how you
use it: if artifacts:paths lists a single file, the link opens that file, and
otherwise it opens the artifact browser. So expose the HTML report on its own if you
want one click to the report. You get one expose_as per job, and GitLab shows at
most 10 jobs per merge request.
The metrics report is the one that puts numbers side by side against the target branch. GitLab's documentation states the tier plainly: "Tier: Premium, Ultimate". It reads an OpenMetrics text file, so you convert the Lighthouse JSON yourself:
script:
- lighthouse http://localhost:3000/ --output=json --output-path=./lh.json
- node ./ci/lh-metrics.mjs > metrics.txt
artifacts:
reports:
metrics: metrics.txt
Each line is a name, a label set and a value, and the file ends with # EOF:
lighthouse_score{category="performance"} 0.92
lighthouse_score{category="accessibility"} 1
# EOF
On GitLab Free that keyword does nothing. Use expose_as instead, and read the HTML
report yourself.
What the pipeline can't tell you
It measures your build, not your site. staticDistDir and a local serve are
the right target for a pre-merge gate. That page has no CDN, no production cache and
often no third-party tags. Your visitors get none of those conditions.
One runner is one machine in one place. A GitLab shared runner tells you what your page costs from that runner's location, on a shared CPU, at that moment. The Largest Contentful PaintTime until the largest thing in view (hero image, headline) has painted. it reports belongs to that machine, not to your users.
A green pipeline is not a fast site. It's the absence of one class of regression, measured once, in one place.
The split we'd suggest: keep the GitLab job as the pre-merge gate on bytes, where it is genuinely good and free. Then measure the deployed URL after the deploy, from the places your users are. If GitHub Actions is also in your life, running Lighthouse in a GitHub Action covers the same fork with a different set of runner traps.
Lightscore does the second half. You POST a URL and pick regions, and the scores come back with the complete Lighthouse JSON and HTML:
curl -s https://lightscore.dev/api/v1/audits \
-H "Authorization: Bearer $LIGHTSCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/","regions":["fra","iad"],"runs":5}'
We pin Lighthouse, and the homepage prints the Lighthouse and Chromium versions of
every run, above the list of regions we test from. We're on Lighthouse 12.8.2. That's the same major as
the copy inside lhci, and one behind current. So apply the advice above to us as
well. Write our version down next to our numbers. Don't compare them to a 13.x run. What we can't reach is ./dist or a review app
behind authentication. A worker in Frankfurt has no route to your GitLab runner. For that half of
the job the pipeline above is the right tool, and we're not a replacement for it.
Common questions
How do I run Lighthouse in GitLab CI?
Add a job that uses the registry.gitlab.com/gitlab-ci-utils/lighthouse image. Start a server for your build, wait for it, then call lighthouse against the local URL. Save the JSON and HTML with artifacts, and add expose_as so the report gets a link in the merge request. The image already contains Chromium, lighthouse, lhci, serve and wait-on.
Do I need --no-sandbox to run Chrome in GitLab CI?
Not always. The gitlab-ci-utils image runs as the non-root user lhuser for exactly this reason, and its documentation says the seccomp policy on GitLab.com shared Linux runners allows the sandbox to work. On a self-managed runner it depends on your seccomp and container settings. Turning the sandbox off is a security decision, not a formatting detail, so try the job without the flag first.
What Lighthouse version does the GitLab Lighthouse image use?
Two different ones, depending on the command. In the image pulled on 2 September 2026, the lighthouse CLI reported 13.4.1 and lhci reported 0.15.1. Lighthouse CI carries its own nested copy of Lighthouse, pinned at 12.6.1. So lighthouse and lhci autorun in the same job grade your page with different scoring code.
How do Lighthouse results show up in a GitLab merge request?
On any tier, use artifacts:expose_as to put a link to the HTML report in the merge request widget. Expose a single file and the link opens the report directly. The metrics report, which compares numbers between the source and target branch, needs GitLab Premium or Ultimate and an OpenMetrics text file.
Can I run Lighthouse CI in a plain node image?
Not without extra work. The official node images ship no browser at all — chromium, chromium-browser, google-chrome and google-chrome-stable are all absent from node:24-bookworm-slim. Use an image that includes a browser, or install one and point CHROME_PATH at it.