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 RunThe 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.devand preview URLs by default.
git clone https://github.com/harlan-zw/unlighthouse.git
cd unlighthouse
pnpm install
cd apps/cloudflare
wrangler loginProvision storage
wrangler d1 create unlighthouse
wrangler r2 bucket create unlighthouseEdit wrangler.jsonc:
- Replace the D1
database_id. - Replace
CF_ACCOUNT_ID. - Set
UNLIGHTHOUSE_ALLOWED_ORIGINSto a comma-separated exact-origin allowlist and set the matching site inUNLIGHTHOUSE_CONFIG. - 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: EditOptional provider keys add quota or a fallback tier:
wrangler secret put PSI_API_KEY
wrangler secret put CRUX_API_KEYFor 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 deploypnpm 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 tailThe 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 unlighthouseSee the app's README.md
for the current command and binding contract.