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.

Resource & Configuration Reference

The stack is two services plus two volumes. Sizing is modest — the server is a thin Express process and Postgres is the only memory-hungry component.

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.

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.

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.

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.

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.

{
  "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}"
    }
  }
}

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.

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. A containerized Postgres handles concurrent uploads and scales 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.