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, and how long they are retained.

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.

LHCI server data flow from CI upload to dashboard and API CI runners upload median Lighthouse reports authenticated by a build token to the self-hosted LHCI server. The server writes to a SQLite or PostgreSQL store and serves both a build-comparison dashboard UI and a JSON API that an external tool such as Grafana can query. CI runner lhci autorun build token LHCI server :9001 (Express) auth + upload API build comparison retention pruning Dashboard UI trend charts JSON API → Grafana SQL store SQLite / Postgres
Runners authenticate with a build token and upload median reports; the server persists to a SQL store and serves both a trend dashboard and a JSON API for external tools.

Prerequisites & 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/cli your 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.

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": false,
      "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.

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

  1. 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=9001

    Expected output: Listening on port 9001 and a Saving server data to /data/lhci.db line. The server is now accepting admin requests.

  2. Create a project and mint a build token with the interactive wizard. This is the only step that uses the admin token.

    npx lhci wizard

    Answer new-project, point it at http://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 the LHCI_TOKEN CI secret and keep the admin token in a password manager.

  3. 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 ..., then Uploading median LHR ... success! and a URL into the server's build-comparison view.

  4. Enable retention pruning so the store does not grow without bound. Run the server with a delete batch size; 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.

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

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.

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.

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 → assert → 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.

Troubleshooting & Edge Cases

  • 401 Unauthorized on upload → the build token is wrong or belongs to a different project; re-run lhci wizard against the existing project to reissue, and confirm LHCI_TOKEN is set in the job's env, 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 sqlDatabasePath directory.
  • 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 builds in logs).
  • Storage grows unbounded → no deleteOldBuildsCron is configured; add it and confirm the retention window in the calibration table is being applied.
  • sqlConnectionSsl errors against managed Postgres → set "sqlConnectionSsl": true and supply the CA via sqlConnectionUrl query params; most managed providers reject non-TLS connections.
  • Mixed CLI/server versions reject uploads → align @lhci/cli and @lhci/server to 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.

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.