Setting Up Lighthouse CI for a Static Site
Running Lighthouse by hand in DevTools tells you how one page performed once, on your machine, today. Lighthouse CI runs the same audits on every pull request, against the same URLs, with the same settings, and fails the check when a budget is crossed. For a static site, where every change produces a complete deployable build, it slots in naturally after the preview deploy and takes about an hour to set up.
This guide wires it into GitHub Actions for an Astro site with five templates, but nothing here is Astro-specific — the same workflow works for Hugo, Eleventy, Jekyll or a Next.js static export. The reasoning behind the budgets it enforces is in Performance Budgets and Lighthouse CI.
Prerequisites
- A static site built in GitHub Actions.
- A preview deploy per pull request with a URL the workflow can read — see Automating Preview Deploy Pipelines with GitHub Actions.
- A list of one representative URL per template, plus the homepage.
- Admin access to the repository to make the new check required.
Step 1: Pick the URLs
Performance is a property of templates. List each layout the site uses and pick the heaviest real page for each — the longest guide, the post with the largest hero image, the reference page with the biggest table. The heaviest page is the one most likely to cross a budget first.
// lighthouse/urls.js
module.exports = (base) => [
`${base}/`, // home
`${base}/guides/`, // section index
`${base}/guides/migrating-a-large-docs-site/`, // longest guide
`${base}/blog/2026/image-pipeline-deep-dive/`, // largest hero image
`${base}/reference/configuration/`, // biggest table
];
Keep this list in the repository and update it when a template is added. A template without a URL here has no performance check at all.
Step 2: Write the Configuration
lighthouserc.js has three sections: collect (what to run), assert (what must pass) and upload (where reports go).
// lighthouserc.js
const urls = require('./lighthouse/urls');
module.exports = {
ci: {
collect: {
url: urls(process.env.PREVIEW_URL),
numberOfRuns: 3,
settings: {
throttlingMethod: 'simulate',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
skipAudits: ['uses-http2'],
},
},
assert: {
preset: 'lighthouse:no-pwa',
assertions: {
'categories:performance': ['warn', { minScore: 0.9, aggregationMethod: 'median-run' }],
'resource-summary:script:size': ['error', { maxNumericValue: 102400 }],
'resource-summary:image:size': ['warn', { maxNumericValue: 307200 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500, aggregationMethod: 'median' }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1, aggregationMethod: 'median' }],
'total-blocking-time': ['warn', { maxNumericValue: 200, aggregationMethod: 'median' }],
'unsized-images': 'error',
'render-blocking-resources': 'warn',
},
},
upload: { target: 'temporary-public-storage' },
},
};
The lighthouse:no-pwa preset turns on sensible defaults for most audits; the explicit assertions override it for the metrics you care about. aggregationMethod: 'median' evaluates the median of the three runs rather than failing if any single run crosses the line, which is what makes timing assertions usable on shared runners.
Step 3: Add the Workflow Job
Run Lighthouse CI in a job that depends on the preview deploy and receives its URL as an output:
# .github/workflows/preview.yml (excerpt)
jobs:
deploy-preview:
runs-on: ubuntu-latest
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
# ... build and deploy, setting steps.deploy.outputs.url
lighthouse:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Run Lighthouse CI
env:
PREVIEW_URL: ${{ needs.deploy-preview.outputs.url }}
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
run: npx -y @lhci/cli@0.14.x autorun
- name: Keep raw results
if: always()
uses: actions/upload-artifact@v4
with: { name: lighthouse, path: .lighthouseci/, retention-days: 90 }
autorun runs collect, assert and upload in sequence and exits non-zero on any error. The optional LHCI_GITHUB_APP_TOKEN (from installing the Lighthouse CI GitHub App) adds a status per URL with a link to each report; without it, the job's own pass or fail is the status. Pin the CLI to a minor version so a Lighthouse upgrade — which can shift metric values — happens deliberately.
Step 4: Make It Required
In the repository's branch protection rules for main, add the lighthouse job as a required status check. Until you do, a failing budget is a red icon people learn to ignore. Enable it once the job has passed on a handful of pull requests, so the first people to hit a failure are not surprised by a check they never saw pass.
If the preview deploy is skipped for a pull request — documentation-only changes on some setups — the Lighthouse job is skipped too, and GitHub treats a skipped required check as passing. That is usually the right behaviour; if not, run the job unconditionally against the production URL as a fallback.
Step 5: Read a Failure
When the check fails, the job log lists each failing assertion with the measured value and the threshold:
✘ largest-contentful-paint failure for maxNumericValue assertion
expected: <=2500
found: 3180
all values: 3110, 3180, 3240
url: https://pr-482.example.pages.dev/blog/2026/image-pipeline-deep-dive/
All three runs were over the limit, so this is a real regression rather than noise. The linked report's LCP element and "LCP breakdown" audit show where the time went — in this case, a hero image served at 2400 px wide without srcset, fixed by routing it through the image pipeline described in Reducing LCP from Hero Images on Static Sites.
Extending the Setup
Once the basic check is trusted, three extensions are worth the extra few minutes each.
Test desktop as well as mobile for templates with different layouts. Lighthouse's default is a mobile profile. If your homepage swaps a text hero on mobile for a large image on desktop, the desktop LCP element is different and can regress independently. Add a second collect pass with settings.preset: 'desktop' for the two or three templates where layout diverges, and budget them separately.
Test a warm, repeat view. Readers of documentation sites usually view several pages per session, so their second page load hits a warm HTTP cache. A Puppeteer script passed to collect.puppeteerScript can visit the homepage first and then let Lighthouse measure the target page with the cache populated. The warm LCP on this site was 0.7 s against 1.9 s cold, and a caching-header regression that left warm LCP unchanged in cold tests showed up immediately in this pass. The headers themselves are covered in Setting Cache-Control Headers on Cloudflare Pages.
Run the same config against production on a schedule. A nightly workflow that runs Lighthouse CI against the live site with the same URL list and assertions catches regressions that never went through a pull request: an expired CDN rule, a third-party script changing behind a stable URL, a vendor tag added through a tag manager. Post failures to the team channel instead of blocking anything.
Measured Impact
Three months after the setup went live on the five-template site:
| Measure | Value |
|---|---|
| Pull requests checked | 212 |
| Check duration (median) | 4 min 12 s |
| Failures | 44 (41 real, 3 cleared on re-run) |
| Most common cause | unoptimised images (17), new dependencies (11), render-blocking CSS (6) |
| Script bytes on guide template, start → end | 38 KB → 39 KB |
| Field LCP p75 (mobile), start → end | 2.2 s → 1.9 s |
Pitfalls & Rollback
- Testing
staticDistDirforever. It is a fine start, but a local server has no compression or CDN. Move to the preview URL once one exists. - Asserting on every audit. The full preset contains audits that do not apply to your site; turn off the irrelevant ones rather than letting warnings pile up.
- Unpinned CLI. A new Lighthouse version can move metrics by 5–10%. Upgrade deliberately and re-baseline.
- Forgetting new templates. A layout missing from the URL list is unchecked. Add a URL in the same pull request that adds the template.
- Required too early. Make the check required only after it has passed on several pull requests, so the first failure is trusted.
- Rollback: the setup is one config file, one URL list and one workflow job. Removing the job from branch protection makes it advisory; deleting the job removes it entirely.
Conclusion
Lighthouse CI turns performance from something checked occasionally into something checked on every pull request. Five URLs, three runs each, median-based assertions, byte budgets as errors and a required status check took about an hour to set up and four minutes per pull request to run. Over three months it caught 41 real regressions — mostly images and dependencies — before they reached readers, while field LCP improved rather than drifting.
FAQ
Can Lighthouse CI test a static build without deploying it?
Yes, with collect.staticDistDir it serves the build folder on a local server. That is fine for a first setup, but it misses compression, CDN caching and redirects, so testing the real preview deploy gives numbers much closer to what readers see.
How long does Lighthouse CI add to a pipeline?
About 15 to 20 seconds per run on a GitHub Actions runner. Five URLs with three runs each took just over four minutes, running after the preview deploy and in parallel with other checks.
Where are the reports stored?
Temporary public storage keeps reports for about a week and prints a link per URL, which is enough to start. For history and trend graphs, run a Lighthouse CI server or upload the JSON results as build artifacts to your own storage.
Should Lighthouse CI block merges?
Yes, once assertions are stable. Make the job a required status check in branch protection, with byte budgets as errors and noisy timing metrics as warnings until you have a few weeks of data.
Related
- Parent: Performance Budgets and Lighthouse CI — what to budget and why.
- Writing a Performance Budget That Fails Builds — choosing the numbers in the config.
- Reducing Lighthouse Score Variance in CI — when timing assertions are noisy.
- Auditing Third-Party Scripts with Lighthouse — reading the third-party sections of a report.
- Running Smoke Tests Against a Preview URL — the functional check that runs alongside.