You merged a PR last Tuesday. Looked clean — new feature, tests green, code review approved. Then three days later someone opens a ticket: "the homepage feels sluggy." You check, and your LCP jumped from 1.8s to 4.2s. The PR that caused it is already three merges deep.
This is a solved problem. Lighthouse CI running as a GitHub Actions gate would have blocked that merge at the PR stage. This article shows you exactly how to set it up — with performance budgets, hard assertion thresholds, and a server to track scores over time.
The official project lives at github.com/GoogleChrome/lighthouse-ci. Read the README once; it’s dense but accurate.
Why Lighthouse in CI and Not Just Locally
Running Lighthouse locally is fine for development. It’s terrible for enforcement. Developers forget to run it, machine specs vary wildly, and nobody checks scores before pushing. A CI gate removes the human variable entirely.
Lighthouse CI (lhci) is the headless, automatable version of Lighthouse. It runs audits against a built copy of your app, compares results against your defined thresholds, and exits non-zero if anything fails. GitHub Actions treats a non-zero exit as a failure, which blocks the PR merge. That’s the entire mechanism — simple, reliable.
The Setup Overview
Here’s what we’re building:
- A GitHub Actions workflow that builds your app and runs
lhci autorun - A
lighthouserc.js(or.json) config with assertion budgets - Optional: a self-hosted LHCI server to persist historical data
You don’t need the server for regression gates. The server is for the pretty dashboard and trend graphs. Gates work purely through assertions in the config file.
Step 1: Install LHCI in Your Project
Add it as a dev dependency:
npm install --save-dev @lhci/cli
Or if you use Yarn:
yarn add --dev @lhci/cli
You can also run it without installing via npx @lhci/cli, but pinning the version in package.json is smarter — you want reproducible builds, not surprise version bumps.
Step 2: Write the LHCI Config
Create lighthouserc.js in your project root. The JSON format works too, but the JS format lets you add comments and use environment variables cleanly.
// lighthouserc.js
module.exports = {
ci: {
collect: {
// Build command to run before auditing.
// LHCI will spin up a static server automatically after this.
staticDistDir: './dist',
// How many times to run Lighthouse per URL.
// 3 runs and take the median — more stable than a single run.
numberOfRuns: 3,
// URLs to audit. Relative paths work with staticDistDir.
url: [
'https://cd-linux.club/index.html',
'https://cd-linux.club/about/index.html',
],
},
assert: {
// Preset gives you a baseline set of assertions.
// 'lighthouse:recommended' is strict. Start with 'lighthouse:no-pwa'
// if PWA audits are irrelevant to your stack.
preset: 'lighthouse:no-pwa',
// Assertions override or extend the preset.
// 'off' | 'warn' | 'error'
// 'error' causes lhci to exit 1 (blocks the PR).
assertions: {
// Core Web Vitals — hard gates
'categories:performance': ['error', { minScore: 0.85 }],
'categories:accessibility': ['error', { minScore: 0.90 }],
'categories:best-practices': ['warn', { minScore: 0.90 }],
'categories:seo': ['warn', { minScore: 0.85 }],
// Specific metrics — more surgical than category scores
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 3000 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'speed-index': ['warn', { maxNumericValue: 3500 }],
// Resource size budgets — catch accidental JS bundle bloat
'resource-summary:script:size': ['error', { maxNumericValue: 300000 }], // 300 KB
'resource-summary:total:size': ['error', { maxNumericValue: 1500000 }], // 1.5 MB
'resource-summary:image:size': ['warn', { maxNumericValue: 500000 }],
// Gotcha: 'uses-optimized-images' fires for lossy compression.
// If your pipeline already handles this, downgrade to warn.
'uses-optimized-images': 'warn',
// Third-party JS is often unavoidable (analytics, chat widgets).
// Suppress the audit noise rather than lying about your score.
'third-party-summary': 'off',
},
},
upload: {
// 'temporary-public-storage' uploads results to Google's temporary server.
// Fine for open-source, bad for proprietary apps. Use your own server instead.
// target: 'temporary-public-storage',
// Comment this out until you have a self-hosted server.
target: 'filesystem',
outputDir: '.lighthouseci',
},
},
};
A few things worth explaining here:
The staticDistDir approach is the easiest for SPAs and static sites — LHCI starts its own web server pointed at your build output. If your app requires a real backend (SSR, API routes), you’ll use startServerCommand instead (covered in the Gotchas section).
The numberOfRuns: 3 setting matters. A single Lighthouse run has meaningful variance — CPU throttling, GC pauses, whatever. Three runs and the median gives you reproducibility. Don’t go below 3 in CI.
Step 3: The GitHub Actions Workflow
Create .github/workflows/lighthouse.yml:
name: Lighthouse CI
on:
pull_request:
branches: [main, master]
# Also run on pushes to main to keep the historical baseline fresh
push:
branches: [main, master]
jobs:
lighthouse:
name: Run Lighthouse
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
env:
# Suppress source maps in CI — they inflate bundle size reports
GENERATE_SOURCEMAP: false
NODE_ENV: production
- name: Run Lighthouse CI
run: npx lhci autorun
env:
# Required if you're using a self-hosted LHCI server
# LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
# LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
# Chromium flags for headless CI environments
# These are already set by the lhci action internally,
# but explicit is better than implicit
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage"
- name: Upload Lighthouse results as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-results
path: .lighthouseci/
retention-days: 30
The if: always() on the artifact upload step is deliberate — you want the reports even when the job fails. That’s when you actually need to read them.
Step 4: The lhci autorun Magic
lhci autorun reads your lighthouserc.js and runs three phases sequentially:
- collect — builds (if configured) and audits your URLs
- assert — checks results against your thresholds, exits 1 on errors
- upload — pushes results to your configured target
If any assertion fires as error, the process exits with a non-zero code, the GitHub Actions step fails, and the PR is blocked. That’s your regression gate.
Gotchas
Gotcha 1: Chrome not found
On ubuntu-latest, Chromium is installed. On self-hosted runners or stripped-down images, it might not be. Add this before your LHCI step if you hit Chrome-not-found errors:
- name: Install Chromium
run: |
sudo apt-get update -qq
sudo apt-get install -y chromium-browser
Or use the official treosh/lighthouse-ci-action which handles Chrome for you. The tradeoff: less control over the LHCI version.
Gotcha 2: Apps with a real server
staticDistDir only works if your app is fully static after build. Next.js with SSR, Express apps, anything with server-side rendering — you need startServerCommand:
collect: {
startServerCommand: 'npm run start',
startServerReadyPattern: 'listening on port',
startServerReadyTimeout: 30000,
url: ['https://cd-linux.club:3000/', 'https://cd-linux.club:3000/about'],
numberOfRuns: 3,
},
The startServerReadyPattern is a regex matched against the server’s stdout. Get it wrong and LHCI will hit the URL before the server is ready, giving you garbage results.
Gotcha 3: Flaky scores from throttling
Lighthouse applies CPU and network throttling by default. In CI, this means your 4x CPU throttle runs on a VM that’s already under load from other jobs. Scores can swing 5-10 points between runs.
If you’re seeing consistent flakiness, two options:
Option A — disable throttling for consistent (but unrealistic) scores:
collect: {
settings: {
throttling: {
rttMs: 0,
throughputKbps: 0,
cpuSlowdownMultiplier: 1,
},
},
},
Option B — relax your thresholds by 5-10 points and keep throttling. More honest, harder to pass initially.
I prefer Option B. The whole point is to catch regressions relative to a baseline, not to hit a perfect score on throttled hardware.
Gotcha 4: Single-page apps with client-side routing
If your app is a React/Vue SPA where routes don’t map to real HTML files, staticDistDir with multiple URLs will fail — the server only has index.html. You need a proper server that serves index.html for all routes:
collect: {
startServerCommand: 'npx serve -s dist -l 3000',
startServerReadyPattern: 'Accepting connections',
url: [
'https://cd-linux.club:3000/',
'https://cd-linux.club:3000/dashboard',
'https://cd-linux.club:3000/profile',
],
numberOfRuns: 3,
},
npx serve -s (static fallback mode) handles this.
Gotcha 5: Score categories vs. individual audits
categories:performance is the aggregate 0-100 score. It’s a blunt instrument — you can fail LCP badly and still pass the category if you ace everything else. Individual metric assertions are far more useful as gates. Use both: category score as a floor, individual metrics as precise gates.
Gotcha 6: Budget assertions and percentile thresholds
By default, assertions apply to the median run. You can target the pessimistic (p75) run instead:
assertions: {
'largest-contentful-paint': ['error', {
maxNumericValue: 3000,
aggregationMethod: 'pessimistic',
}],
},
pessimistic catches variance spikes. For production gates I always use pessimistic — your worst run is what some percentage of real users experience.
Optional: Self-Hosted LHCI Server
The temporary-public-storage upload target is fine for quick wins, but it’s a public URL with a 30-day TTL. For a proper trend dashboard on private code, run your own server.
The LHCI server is a Node.js app with a SQLite backend. Here’s a minimal Docker Compose setup:
# docker-compose.yml for LHCI server
services:
lhci:
image: patrickhulce/lhci-server:latest
container_name: lhci-server
restart: unless-stopped
ports:
- "9001:9001"
volumes:
# Persist the SQLite database and uploaded reports
- lhci_data:/data
environment:
LHCI_STORAGE__SQL_DATABASE_PATH: /data/lhci.db
# Optional: basic auth token validation
# LHCI_BASIC_AUTH__USERNAME: admin
# LHCI_BASIC_AUTH__PASSWORD: changeme
volumes:
lhci_data:
docker compose up -d
Once it’s running, create a project and grab a build token:
npx lhci wizard
# Select: "Add a project to an existing LHCI server"
# Enter your server URL: http://your-server:9001
# It will print a LHCI_TOKEN — store this as a GitHub secret
Then update your lighthouserc.js upload section:
upload: {
target: 'lhci',
serverBaseUrl: process.env.LHCI_SERVER_BASE_URL,
token: process.env.LHCI_TOKEN,
},
And your workflow:
- name: Run Lighthouse CI
run: npx lhci autorun
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
The server dashboard at http://your-server:9001 gives you run history, score trends, and diff comparisons between commits.
Production-Ready Tips
Baseline the initial numbers before setting thresholds. Run LHCI against your current production build first, check what scores you actually get, then set thresholds at -5 to -10 points below current. You want to catch regressions, not fail every existing PR on day one.
Separate warn from error deliberately. Use error only for metrics that directly hurt users: LCP, TBT, CLS, and hard resource budgets. Use warn for things that are good practice but not user-impacting today (SEO meta tags, unused CSS). A noisy gate that warns on everything trains developers to ignore it.
Pin your LHCI version in package.json. Patch releases occasionally change how audits score. An LHCI upgrade causing a score drop is not a regression in your app — it’s audit methodology drift. You want those two things separated.
Don’t audit every URL on every PR. If you have 50 pages, auditing all of them per PR is slow and noisy. Audit your critical paths only: homepage, core feature page, checkout flow. Add more coverage via scheduled runs on main rather than per-PR gates.
Use environment-specific configs. Your preview deployment URL won’t be localhost. Use LHCI_BUILD_CONTEXT__CURRENT_BRANCH and construct URLs dynamically:
const urls = process.env.PREVIEW_URL
? [`${process.env.PREVIEW_URL}/`, `${process.env.PREVIEW_URL}/about`]
: ['https://cd-linux.club/index.html', 'https://cd-linux.club/about/index.html'];
module.exports = {
ci: {
collect: { url: urls, numberOfRuns: 3, staticDistDir: './dist' },
// ...
},
};
Full Working Example
Here’s a minimal but complete setup for a Vite-built React app:
lighthouserc.js — thresholds that will actually pass a reasonable app
module.exports = {
ci: {
collect: {
staticDistDir: './dist',
numberOfRuns: 3,
url: ['https://cd-linux.club/'],
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.80 }],
'categories:accessibility': ['error', { minScore: 0.90 }],
'largest-contentful-paint': ['error', { maxNumericValue: 3500, aggregationMethod: 'pessimistic' }],
'total-blocking-time': ['error', { maxNumericValue: 500, aggregationMethod: 'pessimistic' }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.15 }],
'resource-summary:script:size': ['error', { maxNumericValue: 500000 }],
'uses-text-compression': 'warn',
'third-party-summary': 'off',
},
},
upload: {
target: 'filesystem',
outputDir: '.lighthouseci',
},
},
};
.github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
env:
NODE_ENV: production
- run: npx lhci autorun
- uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-results-${{ github.run_id }}
path: .lighthouseci/
retention-days: 14
That’s it. Push this, open a PR, and you’ll see the Lighthouse job in your checks. Merge protection rules in GitHub (Settings → Branches → Require status checks to pass) make the gate mandatory — nobody merges without a green Lighthouse run.
Performance regressions are boring bugs. They creep in slowly, they’re hard to bisect after the fact, and they cost you users before anyone notices. An automated gate costs maybe 3 minutes of CI time per PR and eliminates the entire class of problem. The setup above takes 30 minutes to deploy. The math is obvious.