Deploying the LHCI Server with Docker
Running the Lighthouse CI server as a bare npx lhci server process works until the box reboots, the SQLite file lands on an ephemeral disk, or a second CI job races the first into a SQLITE_BUSY lock and the upload silently fails. Containerizing it with a Postgres backend and a named volume removes all three failure modes at once. This guide is the deployment procedure for Self-Hosting the Lighthouse CI Server: a single docker-compose.yml that brings up the server plus PostgreSQL, persists data across restarts, and hands you the build and admin tokens your pipeline needs.
The target is a stack you can docker compose up -d on any host with Docker installed, that survives reboots, and that a CI runner can upload to using the target: "lhci" settings from Lighthouse CI Configuration & Storage. Once it is holding history, you can also point Scheduling Nightly Lighthouse Runs at the same server so that trends accumulate outside of pull-request traffic.
Resource and Configuration Reference
The stack is two services plus one volume, fronted by a TLS-terminating reverse proxy. Sizing is modest — the server is a thin Express process and Postgres is the only memory-hungry component. The values below are steady-state minimums for a team pushing a few hundred builds per day; double the Postgres allocation before you cross roughly 50,000 stored builds.
| Component | Image | Port | Persistent volume | Minimum resources |
|---|---|---|---|---|
| LHCI server | patrickhulce/lhci-server |
9001 | (state via DB) | 256 MB RAM / 0.25 vCPU |
| PostgreSQL | postgres:16-alpine |
5432 (internal) | lhci-pgdata → /var/lib/postgresql/data |
512 MB RAM / 0.5 vCPU |
| Reverse proxy (TLS) | your choice | 443 → 9001 | — | 128 MB RAM |
Only the server port is published to the host, and ideally only to a reverse proxy that terminates TLS — build tokens must never cross the network in cleartext. Postgres stays on the internal compose network with no published port, so nothing but the server can reach the database. The diagram below shows how the request and storage paths fit together.
Choosing the Storage Backend
Before writing any YAML, decide whether you actually need Postgres. The trade is concurrency and scale against one extra container to operate. A single engineer running an occasional manual audit is fine on the built-in SQLite file. A shared team CI, where several pull requests can upload within the same second, is not — SQLite serializes writers and the loser of the race sees its upload fail. The matrix below is the decision in one glance.
Diagnostic Steps
Before deploying, confirm Docker is healthy and the target port is free.
docker --version && docker compose version
# Docker version 26.x, Docker Compose version v2.x
ss -ltnp | grep ':9001' || echo "port 9001 free"
# port 9001 free
If 9001 is already bound, change the published port in the compose file rather than killing the existing process blindly — that other listener might be a running server holding live data.
Implementation
Write this docker-compose.yml. It pins both images, mounts a named volume for the database, wires the server to Postgres over the internal network, and adds health checks so the server only accepts traffic once the database is ready.
services:
lhci-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: lhci
POSTGRES_PASSWORD: ${LHCI_DB_PASSWORD}
POSTGRES_DB: lhci
volumes:
- lhci-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lhci -d lhci"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lhci-net
lhci-server:
image: patrickhulce/lhci-server:0.13.x
restart: unless-stopped
depends_on:
lhci-db:
condition: service_healthy
ports:
- "127.0.0.1:9001:9001"
environment:
LHCI_STORAGE__STORAGE_METHOD: sql
LHCI_STORAGE__SQL_DIALECT: postgres
LHCI_STORAGE__SQL_CONNECTION_URL: "postgresql://lhci:${LHCI_DB_PASSWORD}@lhci-db:5432/lhci"
LHCI_STORAGE__SQL_CONNECTION_SSL: "false"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:9001/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
networks:
- lhci-net
volumes:
lhci-pgdata:
networks:
lhci-net:
Create a .env file beside it with LHCI_DB_PASSWORD= set to a strong secret, then bring the stack up:
echo "LHCI_DB_PASSWORD=$(openssl rand -hex 24)" > .env
docker compose up -d
docker compose ps
Both services should report running (healthy) within about a minute. The depends_on ... condition: service_healthy clause guarantees the server never starts before Postgres can accept connections, which is the usual cause of a crash-looping server on first boot. The startup gate runs as a two-lane sequence: the database container comes up and polls itself with pg_isready until it reports healthy, and only then does the blocked server container release and begin its own healthz check.
Token Setup
The server is up but has no projects yet. Run the wizard from inside the running container so it talks to the server over localhost, then capture the tokens it prints.
docker compose exec lhci-server lhci wizard
Answer the prompts:
- Which wizard? →
new-project - Server base URL? →
http://localhost:9001 - Project name? → e.g.
web-app - Base branch? →
main
The wizard prints a build token and an admin token. Store the build token as the LHCI_TOKEN CI secret and the public server URL (the TLS endpoint your proxy exposes) as LHCI_SERVER_BASE_URL. Keep the admin token in a password manager — it is never needed by CI and authorizes destructive project operations such as deleting an entire project's history. If you host several apps on one server, run the wizard once per project; each gets its own build token, so a leaked token for one project cannot write into another.
Reverse Proxy and TLS
The compose file binds the server to 127.0.0.1:9001, so it is unreachable from outside the host until you place a proxy in front of it. Terminate TLS at that proxy and forward cleartext HTTP to the loopback address. The single rule that matters: the build token rides in an HTTP header on every upload, so if any hop between the runner and the server is unencrypted, that token is exposed. Publishing the container port directly to 0.0.0.0 without TLS is the most common mistake here — keep the bind on loopback and let the proxy own port 443.
Set the proxy's upstream to http://127.0.0.1:9001, forward the Host header, and raise the client-body limit to at least 20 MB — a full Lighthouse report with screenshots and traces can be several megabytes, and a default 1 MB proxy limit will truncate uploads and return a confusing 413.
CI Gating Assertion
Point your pipeline at the deployed server. This upload block — the same one specified in Lighthouse CI Configuration & Storage — sends every median report to your container, while the assert block remains the gate that fails the build. The maxNumericValue of 2500 ms below is a lab LCP ceiling measured under Lighthouse's default mid-tier mobile CPU and Fast 3G emulation; treat it as a proxy for a field P75 LCP target of 2500 ms on mid-range mobile, and calibrate the two against each other using Percentile-Based Threshold Tuning.
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"metric-lcp": ["error", { "maxNumericValue": 2500 }]
}
},
"upload": {
"target": "lhci",
"serverBaseUrl": "${LHCI_SERVER_BASE_URL}",
"token": "${LHCI_TOKEN}"
}
}
}
If you are wiring this into GitHub, the exact job that runs on each pull request is covered in Running Lighthouse CI on Every Pull Request.
Backups and Retention
Because all state lives in the lhci-pgdata volume, one pg_dump is a complete backup. Schedule it nightly and copy the dump off-host; the container images themselves are disposable. A single build row plus its assets is on the order of a few megabytes, so a team storing 300 builds per day will grow the volume by roughly 1 GB per day before pruning. Set a retention window — the server prunes builds older than the configured age on a schedule — so the database does not grow without bound.
docker compose exec -T lhci-db pg_dump -U lhci lhci | gzip > "lhci-$(date +%F).sql.gz"
Verification
Confirm the deployment end to end. First, the container health check:
docker compose exec lhci-server wget -qO- http://localhost:9001/healthz
# {"status":"healthy"}
Then drive a real upload from a machine that has the CLI and the tokens, and watch for the success line:
LHCI_TOKEN=<build-token> LHCI_SERVER_BASE_URL=https://lhci.example.com \
npx lhci autorun --collect.url=http://localhost:8080/
# ...
# Uploading median LHR of http://localhost:8080/...success!
# Open the report at https://lhci.example.com/app/projects/web-app/...
Opening that URL should render the build-comparison dashboard with your first data point. Restart the stack (docker compose restart) and reload — the build must still be there, confirming the Postgres volume is persisting state across restarts. With state confirmed durable, you can point Building a Web Vitals Grafana Dashboard at the server's JSON API and start watching P75 LCP for mid-range mobile trend across builds.
Frequently Asked Questions
Why Postgres instead of the simpler SQLite volume?
SQLite serializes writers, so two CI jobs uploading at once collide on a SQLITE_BUSY lock and one upload fails silently. A containerized Postgres handles concurrent uploads and stays fast past roughly 10,000 builds, where SQLite dashboard queries slow down. For a single-uploader local trial you can still use SQLite — see Self-Hosting the Lighthouse CI Server.
Where do I run lhci wizard if the server is in a container?
Run it inside the running container with docker compose exec lhci-server lhci wizard so it reaches the server over http://localhost:9001 on the internal loopback. Capture the build token for your CI secret and store the admin token separately in a password manager.
Why does the server crash-loop on the first boot after a reboot?
Almost always because it started before Postgres was ready to accept connections. The depends_on with condition: service_healthy plus the database pg_isready health check fixes this by blocking the server until the database reports healthy. If it still loops, check that LHCI_STORAGE__SQL_CONNECTION_URL uses the service name lhci-db, not localhost.
How do I back up the deployment?
All state lives in the lhci-pgdata volume, so a single pg_dump streamed out of the database container is a complete backup. Schedule it nightly, gzip it, and copy it off-host. The images are disposable, so restoring is a fresh docker compose up -d followed by loading the dump.
Do I need a reverse proxy, or can I publish port 9001 directly?
Use a proxy. The build token travels in an HTTP header on every upload, so any unencrypted hop exposes it. Keep the container bound to 127.0.0.1:9001 and let a TLS-terminating proxy own port 443, and raise the proxy body limit to at least 20 MB so multi-megabyte reports are not truncated.