Self-Hosting the Lighthouse CI Server
A storage target of temporary-public-storage throws every Lighthouse run into a shared bucket that expires in days, gives you no trend line, and quietly leaks your staging URLs to a third party. The moment a team wants to answer "is LCP trending up or down across the last 200 builds?" they need a durable, queryable backend they control. That backend is the Lighthouse CI server, and self-hosting it is part of the Dashboarding & Team Adoption reference: it turns a stream of disposable JSON reports into a persistent dataset with a dashboard UI, a REST API, and per-branch baselines that survive runner restarts.
The server is a single Node.js process that accepts authenticated uploads from your CI, writes them to a SQL store, and serves a build-comparison dashboard plus a JSON API. It is the target: "lhci" upload destination referenced in Lighthouse CI Configuration & Storage — this page is the spec for the server side of that contract: where results live, who is allowed to write them, how they are queried, and how long they are retained. If you would rather skip the manual bring-up and run a container stack, the Deploying the LHCI Server with Docker walkthrough wraps everything below in a Postgres-backed docker-compose file with persistent volumes.
Architecture Overview
The server sits between your CI runners and your dashboards. Runners push median reports through a build token; the server persists them to SQLite or PostgreSQL; the UI and API read back from that same store for trend analysis and external visualization. Nothing in the pipeline treats the server as the gate — the pass/fail decision happens in the assert step on the runner, and the upload records the result whether it passed or failed. That separation matters: a red build is exactly the data point you most want on the trend chart, so uploads must succeed even when the assertion fails.
There are three distinct trust boundaries in that picture, and conflating them is the most common self-hosting mistake. The build token is a write-only credential scoped to one project. The admin token is a server-wide credential that can create projects and reissue build tokens. The basic-auth credential in front of the dashboard is purely a read gate on the human-facing UI. Each protects a different surface, and each should be rotated on its own schedule — the build token most often, because it lives in your CI provider's secret store and is exposed to every workflow that uploads.
Prerequisites and Environment
The server is @lhci/server, a self-contained Express application. It needs only a Node runtime, a writable data directory, and a reachable network port — no external services for the SQLite path.
@lhci/server>= 0.13 — match the major/minor of the@lhci/cliyour runners use; the upload wire format is versioned and a mismatch rejects uploads.- Node.js >= 18 — the same runtime baseline as the CLI described in Lighthouse CI Configuration & Storage.
- PostgreSQL 14+ — optional; required past roughly 10,000 builds or when more than one CI job uploads concurrently, because SQLite serializes writers.
- Persistent storage — a durable volume for the SQLite file or the Postgres data directory. A container with an ephemeral filesystem loses every build on restart.
- Reverse proxy with TLS — terminate HTTPS in front of the server so build tokens never cross the wire in cleartext.
Two classes of secret govern the server. An admin token, generated once when the server first boots, authorizes creating projects and reading admin endpoints. A build token, issued per project, is the write credential your CI uses for uploads — it is the LHCI_TOKEN referenced throughout your pipeline. A third, optional credential — the basicAuth username and password — gates the dashboard UI itself and is unrelated to either token; give it to humans, not to CI.
Sizing is undemanding. The server is I/O-bound on report writes, not CPU-bound, so a 1 vCPU / 1 GB instance comfortably absorbs a small team's traffic on SQLite. The variable that actually forces a bigger box is concurrency: the instant two runners upload at the same millisecond against the same SQLite file, one of them gets a SQLITE_BUSY lock error. If your merge queue can produce simultaneous uploads — and any repo running Lighthouse CI on every pull request eventually will — plan for PostgreSQL from the outset rather than migrating under fire.
Configuration Reference
The server reads a JSON config (passed via --config or lighthouserc.json with a server block). Every field below is load-bearing; the annotations are the spec.
{
"server": {
"port": 9001,
"host": "0.0.0.0",
"storage": {
"storageMethod": "sql",
"sqlDialect": "postgres",
"sqlConnectionUrl": "postgresql://lhci:secret@db:5432/lhci",
"sqlConnectionSsl": true,
"sequelizeOptions": { "pool": { "max": 10, "min": 1 } }
},
"basicAuth": {
"username": "lhci",
"password": "${LHCI_BASIC_AUTH_PASSWORD}"
}
}
}
storageMethod: "sql" is the only durable option — the spanner method exists but is rarely warranted. For the SQLite path, swap to "sqlDialect": "sqlite" with "sqlDatabasePath": "/data/lhci.db" and drop the connection URL. basicAuth gates the dashboard UI behind a shared credential; it is independent of the build/admin tokens that protect the API. The pool block caps concurrent Postgres connections so a burst of parallel CI jobs cannot exhaust the database — set max to a value your database plan can actually sustain, since each pooled connection consumes server-side memory.
Two fields deserve special caution. sqlConnectionSsl should be true for any managed Postgres provider; nearly all of them reject cleartext connections outright, and leaving it false produces a connection error that looks like a networking problem but is a TLS-policy problem. And sqlDangerouslyResetDatabase, which does not appear above, must never be true in production — it drops and recreates every table on boot, silently erasing your entire history. Keep it out of the file so a copy-paste accident cannot flip it.
The retention behavior that keeps the store fast is not in this file — it is a separate lhci server invocation flag, set in the step-by-step below.
Step-by-Step Implementation
-
Start the server against a persistent data directory. For a first local run, the SQLite path needs no external database.
npm install --save @lhci/[email protected] npx lhci server \ --storage.storageMethod=sql \ --storage.sqlDialect=sqlite \ --storage.sqlDatabasePath=/data/lhci.db \ --port=9001Expected output:
Listening on port 9001and aSaving server data to /data/lhci.dbline. The server is now accepting admin requests. -
Create a project and mint a build token with the interactive wizard. This is the only step that uses the admin token.
npx lhci wizardAnswer
new-project, point it athttp://localhost:9001, name the project, and supply your Git repository's base branch. The wizard prints a build token and an admin token — store the build token as theLHCI_TOKENCI secret and keep the admin token in a password manager. -
Wire the CI upload target. In your pipeline's
lighthouserc.json, set the upload block to push to the server using the token from step 2.{ "ci": { "upload": { "target": "lhci", "serverBaseUrl": "${LHCI_SERVER_BASE_URL}", "token": "${LHCI_TOKEN}" } } }Trigger a build. Expected tail:
Saving CI project ..., thenUploading median LHR ... success!and a URL into the server's build-comparison view. -
Enable retention pruning so the store does not grow without bound. Run the server with a delete cron; old builds beyond the retention window are pruned in batches rather than one slow transaction.
npx lhci server --storage.storageMethod=sql \ --storage.sqlDialect=postgres \ --storage.sqlConnectionUrl="$DATABASE_URL" \ --storage.sqlDangerouslyResetDatabase=false \ --deleteOldBuildsCron="0 3 * * *"The cron clause prunes nightly at 03:00. Confirm in logs:
Deleted N old builds.
Under the hood, an upload is not a single request. The runner first opens a build, then streams each run's report and its assorted assets, then seals the build so the dashboard treats it as complete. Understanding that handshake is what lets you diagnose a partial upload — a build that shows in the list with zero runs attached almost always means the seal request never arrived because the runner was killed mid-upload.
Choosing a Storage Backend
The single biggest decision is SQLite versus PostgreSQL, and it is genuinely binary — there is no meaningful middle ground and no benefit to Postgres for a small solo project. SQLite is a file; it needs no separate process, no credentials, and no network hop, so a one-person site or a low-traffic internal tool should start there. PostgreSQL earns its keep the moment you have concurrent writers or a dataset large enough that dashboard queries start scanning tens of thousands of rows. The decision tree below encodes the rule: if you can honestly answer "one uploader, modest volume, no high-availability requirement," stay on SQLite; otherwise reach for Postgres before the pain arrives.
Migrating from SQLite to Postgres later is possible but not automatic — there is no built-in importer, so you either export builds through the API and replay them, or accept a fresh baseline and keep the old SQLite file read-only for reference. Because a clean cutover loses continuity in your trend charts, choosing Postgres up front is the cheaper decision whenever you have any doubt about future concurrency.
Threshold Calibration
The two numbers worth calibrating on the server are storage growth per build and the retention window. A single build with three runs across two URLs stores roughly 1.5–3 MB of report JSON. Size the retention window so the active dataset fits comfortably in memory-cached SQL pages; the matrix below gives representative starting points by build volume.
| Build volume | Recommended store | Per-build footprint | Retention window | Pruning cadence |
|---|---|---|---|---|
| < 50 builds/day | SQLite | 1.5–3 MB | 60 days | Nightly |
| 50–200 builds/day | PostgreSQL | 1.5–3 MB | 45 days | Nightly |
| > 200 builds/day | PostgreSQL + read pool | 2–4 MB | 30 days | Twice daily |
Those retention windows translate into concrete disk. Multiply build volume by window by a ~2 MB per-build footprint and you get the retained store size the chart below plots: a modest SQLite team lands near 6 GB, a mid-volume Postgres deployment near 18 GB, and a high-volume merge-queue-heavy repo near 30 GB even after nightly pruning. Provision the volume with at least double that headroom so a pruning job that falls behind for a weekend does not fill the disk and wedge writes.
Retain at least the last 50 builds per branch regardless of the day-based window, so a quiet feature branch still has a baseline to compare against. Tie the window to the lookback used in your Historical Baseline Calibration so the data needed to recompute a baseline never gets pruned out from under it. The same discipline applies to the metric ceilings the server merely records: the assertion thresholds still live on the runner, so a target such as a P75 LCP of 3500 ms on mid-range mobile over Fast 3G is enforced in assert, and the server's only job is to keep enough history that you can watch that P75 drift over the last several weeks.
Security and Network Hardening
The server ships with no TLS of its own and, on the API surface, only token authentication — which is exactly why it belongs behind a reverse proxy. Terminate HTTPS at nginx, Caddy, or your ingress controller, and forward cleartext to the server on the loopback interface only. Never expose port 9001 to the public internet directly, because a build token traveling over plain HTTP is a token anyone on the path can replay to poison your dataset.
server {
listen 443 ssl;
server_name lhci.internal.example.com;
ssl_certificate /etc/ssl/certs/lhci.pem;
ssl_certificate_key /etc/ssl/private/lhci.key;
location / {
proxy_pass http://127.0.0.1:9001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
}
The proxy_read_timeout matters more than it looks: a build with many runs and large screenshots can take longer than the default 60 seconds to stream, and a proxy that closes the connection early produces exactly the half-written build described earlier. Set it to at least 120 seconds and the seal request will arrive.
Rotate the build token on a schedule and immediately after any suspected leak. Because the token lives in your CI secret store, rotation is a two-step dance: reissue with lhci wizard against the existing project, update the LHCI_TOKEN secret, and only then revoke the old token so in-flight builds are not orphaned. If your dashboard is reachable to a broad internal audience, keep basicAuth in place even behind a VPN — defense in depth costs nothing here and stops a curious colleague from browsing another team's staging URLs. When the same performance data also flows to a broader observability stack, mirror those access controls there too; the Integrating Performance Budgets With Datadog path, for instance, becomes a second copy of the same trend data and deserves the same scrutiny.
Querying the API and Backing Up
The dashboard is only one consumer of the store; the JSON API is the other, and it is what makes the server useful beyond its built-in UI. Every project, branch, build, and per-metric statistic is reachable through stable /v1/ endpoints, which is precisely how an external dashboard pulls long-horizon trends. A read-only script can walk projects to builds to statistics with nothing more than the public project ID.
BASE="https://lhci.internal.example.com"
PROJECT_ID="$(curl -s "$BASE/v1/projects/lookup?slug=storefront" | jq -r '.id')"
curl -s "$BASE/v1/projects/$PROJECT_ID/branches/main/builds?limit=25" \
| jq -r '.[] | [.id, .commitMessage, .createdAt] | @tsv'
For the metric values themselves, the /statistics endpoint on a build returns each audit's numeric result, which you can post-process into the same percentile rollups your budgets are stated in. This is the exact wiring the Visualizing Budget Trends With Grafana page uses as a data source, and the same API feeds any Performance Budget Reporting and Scorecards rollup you build for non-engineering stakeholders.
Backups are undramatic but essential. On SQLite, the store is one file — copy it while the server is briefly stopped, or use the sqlite3 .backup command for a hot copy. On Postgres, run pg_dump on the pruned database nightly, after the retention cron so you are not backing up rows you are about to delete. Test a restore at least once; a backup you have never restored is a hypothesis, not a safety net.
CI Enforcement Snippet
The server is the upload sink, not the gate — the gate is the assert step that exits non-zero on a budget breach. This job runs the audit, fails the build on a breach, and uploads to your self-hosted server regardless so even failing builds are recorded for trend analysis. The same pattern generalizes cleanly to scheduled runs; the Scheduling Nightly Lighthouse Runs approach points its uploads at this very server so overnight synthetic drift lands on the same trend line as pull-request builds.
name: Performance Gating
on:
pull_request:
branches: [main]
jobs:
lighthouse-ci:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- name: Collect and assert
run: npx lhci autorun
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
Because autorun runs collect, then assert, then upload in order, an error-level assertion breach fails the job before upload only if you reorder the steps; the default keeps upload.target: "lhci" firing on every run so the dashboard always has the latest data point. Require the lighthouse-ci status check in branch protection so a breach is unmergeable, exactly as covered in Running Lighthouse CI on Every Pull Request.
Troubleshooting and Edge Cases
401 Unauthorizedon upload — the build token is wrong or belongs to a different project; re-runlhci wizardagainst the existing project to reissue, and confirmLHCI_TOKENis set in the job'senv, not just the repo.- Builds vanish after a container restart — the SQLite file or Postgres volume is ephemeral; mount a named persistent volume at the
sqlDatabasePathdirectory. - A build appears with zero runs — the upload was interrupted before the seal request; raise the reverse proxy's read timeout to 120s and confirm the runner is not being killed by a job timeout mid-upload.
- Dashboard queries crawl past ~10k builds — SQLite is serializing reads; migrate to PostgreSQL and add a connection
pool, then verify pruning is actually deleting (Deleted N old buildsin logs). - Storage grows unbounded — no
deleteOldBuildsCronis configured; add it and confirm the retention window in the calibration table is being applied. sqlConnectionSslerrors against managed Postgres — set"sqlConnectionSsl": trueand supply the CA viasqlConnectionUrlquery params; most managed providers reject non-TLS connections.- Mixed CLI/server versions reject uploads — align
@lhci/cliand@lhci/serverto the same minor; the upload schema is versioned per the spec in Lighthouse CI Configuration & Storage. - Two CI jobs racing the same SQLite file — SQLite locks under concurrent writers and one upload fails with
SQLITE_BUSY; this is the signal to move to PostgreSQL. - History wiped after a deploy — a stray
sqlDangerouslyResetDatabase=truereset every table on boot; remove the flag entirely and restore from your latestpg_dumpor SQLite backup.
Frequently Asked Questions
Do I need PostgreSQL or is SQLite enough?
SQLite is sufficient for a single team below roughly 10,000 builds and where only one CI job uploads at a time. Past that, dashboard queries slow and concurrent uploads hit SQLITE_BUSY locks — migrate to PostgreSQL with a connection pool. Either way, configure deleteOldBuildsCron so the store is pruned. See Lighthouse CI Configuration & Storage for the upload-side settings.
What is the difference between the admin token and the build token?
The admin token is generated once at first boot and authorizes creating projects and reading admin endpoints — keep it in a password manager. The build token is per-project and is the write credential your CI uses to upload; it becomes the LHCI_TOKEN secret. Leaking the build token only lets someone post reports to one project; leaking the admin token compromises the whole server.
How do I get budget trends out of the server and into a real dashboard?
The server exposes a JSON API (/v1/projects/...) that returns build and statistic records. Point a visualization tool at it to build long-horizon trend charts — see Visualizing Budget Trends with Grafana for wiring the API as a Grafana data source.
Does the LHCI server enforce my budgets, or just store results?
It only stores and visualizes results. The pass/fail decision happens on the runner in the assert step before upload, so a threshold like a P75 LCP of 3500 ms on mid-range mobile over Fast 3G is enforced there. Uploads succeed for both passing and failing builds so the trend chart keeps a complete record.
How do I back up the server without losing history?
On SQLite, copy the single database file with the server briefly stopped or use sqlite3 .backup for a hot copy. On PostgreSQL, run pg_dump nightly after the retention cron so you are not archiving rows about to be pruned. Restore at least one backup into a scratch instance to confirm it actually works.