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.

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 to 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.

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/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. 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

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

Authenticated upload handshake between runner, server, and store The CI runner posts a new build authenticated by the build token; the server validates the token against its project, inserts the build and run rows into the SQL store, receives the build id back, and returns a 200 with the build-comparison URL. An invalid token is rejected with a 401 before any write. CI runner LHCI server SQL store POST /v1/builds + build token validate token to project INSERT build, runs, LHRs build id 200 + build-comparison URL 401 if token invalid (no write)
The server validates the build token before any write; a valid token yields a build id and comparison URL, an invalid one returns 401 with no rows created.

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.

SQLite versus PostgreSQL decision tree Starting from the storage decision, if the server has more than roughly ten thousand builds, concurrent uploaders, or a high-availability requirement, choose PostgreSQL with a connection pool; otherwise choose SQLite as a single file on a persistent volume. Both backends require configuring a delete-old-builds cron. Pick a storage backend Over ~10k builds, concurrent uploaders, or HA needed? SQLite single file on /data volume zero external dependencies PostgreSQL 14+ connection pool, replicas scales to concurrent CI No Yes Both: configure deleteOldBuildsCron
Concurrency, volume, and high-availability push you to PostgreSQL; everything else stays on SQLite, and both paths still need retention pruning.

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.

Retained store size by build volume and retention window Estimated retained store size after pruning, at roughly two megabytes per build: a SQLite tier at fifty builds per day over sixty days is about six gigabytes, a Postgres tier at two hundred per day over forty-five days about eighteen gigabytes, and a Postgres-plus-pool tier at five hundred per day over thirty days about thirty gigabytes. 0 10 20 30 6 GB SQLite 50/day, 60d 18 GB Postgres 200/day, 45d 30 GB Postgres+pool 500/day, 30d
Retained store size in gigabytes after pruning, at roughly 2 MB per build — provision at least double the tier's figure as headroom.

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 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.
  • 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 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.
  • History wiped after a deploy — a stray sqlDangerouslyResetDatabase=true reset every table on boot; remove the flag entirely and restore from your latest pg_dump or 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.