Lightscore

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