---
title: "Self-Host Unlighthouse on Cloudflare"
description: "Deploy Unlighthouse with a secured Worker, Workflows, D1, R2, a Lighthouse Container, and Browser Rendering."
canonical_url: "https://unlighthouse.dev/v1/self-host-cloudflare"
last_updated: "2026-10-03T08:07:19.346Z"
---

# 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

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

```sh
git clone https://github.com/harlan-zw/unlighthouse.git
cd unlighthouse
pnpm install
cd apps/cloudflare
wrangler login
```

## Provision storage

```sh
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:

```jsonc
"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:

```sh
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:

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

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

```sh
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:

```sh
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

```sh
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`](https://github.com/harlan-zw/unlighthouse/tree/v1/apps/cloudflare)
for the current command and binding contract.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
