Skip to main content
V1

Self-Host Unlighthouse on Cloudflare

Self-Host Unlighthouse on Cloudflare

The maintained deployment lives in apps/cloudflare. It is a real app rather than a package example: it owns authentication, target policy, bindings, migrations, static assets, observability, retention, and deployment commands. Reusable Cloudflare adapters remain in packages/cloudflare.

Runtime layout

Authenticated Worker + dashboard assets
  ├─ D1: sites, scans, routes, metrics, pack runs
  ├─ R2: Lighthouse reports and other large artifacts
  ├─ ScanWorkflow: durable discovery, retries, progress, pause/resume/cancel
  ├─ RateLimiterDO: globally consistent per-principal scan-start quota
  └─ AuditEntrypoint RPC
       └─ PageSpeed Insights → Lighthouse Container → optional CrUX
              └─ Cloudflare Browser Rendering / Browser Run

The Workflow stores only small URLs and counters. Reports stay in D1/R2. Audit delegation uses a named RPC entrypoint rather than a public callback route.

Prerequisites

  • A Cloudflare Workers plan that supports Containers and Browser Rendering.
  • Browser Rendering enabled for the account.
  • Node 24.13.1 or newer, pnpm, Wrangler 4, and Docker.
  • A custom domain for the Worker. The checked-in configuration disables workers.dev and preview URLs by default.
git clone https://github.com/harlan-zw/unlighthouse.git
cd unlighthouse
pnpm install
cd apps/cloudflare
wrangler login

Provision storage

wrangler d1 create unlighthouse
wrangler r2 bucket create unlighthouse

Edit wrangler.jsonc:

  1. Replace the D1 database_id.
  2. Replace CF_ACCOUNT_ID.
  3. Set UNLIGHTHOUSE_ALLOWED_ORIGINS to a comma-separated exact-origin allowlist and set the matching site in UNLIGHTHOUSE_CONFIG.
  4. Add a custom-domain route, for example:
"routes": [
  { "pattern": "unlighthouse.example.com", "custom_domain": true }
]

Do not enable an arbitrary target. Every requested site and every discovered route must match this allowlist; literal private, loopback, link-local, .local, and .internal targets are rejected as a second guard.

Configure secrets

Use different random values for the user-facing API token and the internal Container token:

wrangler secret put UNLIGHTHOUSE_API_TOKEN  # openssl rand -hex 32
wrangler secret put SHARED_AUDIT_TOKEN      # openssl rand -hex 32
wrangler secret put CF_BROWSER_RUN_TOKEN    # Browser Rendering: Edit

Optional provider keys add quota or a fallback tier:

wrangler secret put PSI_API_KEY
wrangler secret put CRUX_API_KEY

For local development, copy .dev.vars.example to .dev.vars. Never commit that file.

Apply migrations and deploy

D1 schema changes are deploy-time migrations. No request runs DDL.

pnpm db:migrate:local
pnpm types
pnpm typecheck
pnpm test
pnpm deploy:dry-run
pnpm deploy

pnpm deploy builds the Container and dashboard, applies remote D1 migrations, and uploads the Worker. The committed src/worker-configuration.d.ts is generated from wrangler.jsonc; rerun pnpm types whenever bindings change.

The app also exposes pnpm deploy:worker-only when the Container image has not changed.

Authenticate and verify

/health is the only unauthenticated route. API clients use Bearer auth. Browsers use HTTP Basic with username unlighthouse and the same API token as the password.

export APP=https://unlighthouse.example.com
export UNLIGHTHOUSE_API_TOKEN=...

curl -fsS "$APP/health"
curl -fsS \
  -H "Authorization: Bearer $UNLIGHTHOUSE_API_TOKEN" \
  "$APP/api/manifest"

SCAN=$(curl -fsS -X POST \
  -H "Authorization: Bearer $UNLIGHTHOUSE_API_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"site":"https://example.com","device":["mobile"]}' \
  "$APP/api/scan/start" | jq -r .scanId)

curl -fsS \
  -H "Authorization: Bearer $UNLIGHTHOUSE_API_TOKEN" \
  "$APP/api/scan/status?scanId=$SCAN"

Open $APP to use the same-origin dashboard. The Worker executes before static assets, so asset delivery cannot bypass authentication. Protected assets are returned with Cache-Control: private, no-store.

Durability and retention

Multi-page scans run as ScanWorkflow, with stable step names, bounded same-origin discovery, built-in exponential retries, and native pause/resume/terminate controls. The queue is capped at 200 routes and the Workflow is capped at 1,024 steps.

The daily cron calls core retention, which deletes D1 rows and their namespaced R2 artifacts together. Tune the retention block in UNLIGHTHOUSE_CONFIG. Avoid an independent R2 expiry rule unless you intentionally want a hard blob age cap that can override retained CI baselines.

Operations

Workers Logs, invocation logs, and sampled traces are enabled. Use:

pnpm tail

The scan-start limiter is keyed by the authenticated principal, not client IP. The checked-in production default allows a burst of two scans and refills one scan per minute. Adjust RATE_LIMITER_CAPACITY and RATE_LIMITER_REFILL_PER_SEC for your workload.

Durable Object migration history is append-only. Do not edit or reorder an already deployed tag. D1 migrations are likewise checked in under packages/core/migrations/sqlite and applied before deploy.

Tear down

wrangler delete
wrangler d1 delete unlighthouse
# Empty R2 first if it contains objects, then:
wrangler r2 bucket delete unlighthouse

See the app's README.md for the current command and binding contract.

Did this page help you?
Anything that could be done better? :)
Help us improve this page. You can edit this page on GitHub or provide anonymous feedback below.