This guide covers self-hosted production deploys — what every adopter does to run the engine for their own inspection business.
OpenInspection deploys as a single Cloudflare Worker (the cloudflare/react-router-hono-fullstack-template shape):
workers/app.ts— a Hono entry that mounts the full API (server/, Hono + Drizzle + D1) in-process for API-owned paths and delegates all other (page) routes to React Router v7 SSR (app/, React 18 + Tailwind v4).- React Router loaders/actions call the API DIRECTLY through an injected in-process
API_WORKERself-binding — no network hop, no second worker, no Service Binding between workers.
One deployable; npm run deploy builds and ships it.
The recommended path is the one-click button in the README:
[Deploy to Cloudflare] → fork → run `npm run setup:cloudflare`
For the manual flow, see the Quick start section in the README.
Already deployed? To move an existing deployment to a newer release, see
12_upgrade.md— it covers backups, applying new migrations, and verifying the deployed version.
| Resource | Binding | Purpose |
|---|---|---|
| Worker | (one worker) | Single Worker (API in-process + React Router SSR). |
| D1 database | DB |
All structured data (inspections, users, comments, ...). |
| R2 bucket | PHOTOS |
All object storage — field-form photos, pre-rendered report/certificate PDFs, and e-sign evidence packs. |
| KV namespace | TENANT_CACHE |
Branding + tenant-config 1-hour cache. |
| Browser binding | BROWSER |
PDF rendering for reports + e-sign certificates. |
| Images binding | IMAGES (optional) |
Downscales Appendix B photos before embedding them in Word (.docx) exports. |
| Workflow | SIGN_COMPLETION_WORKFLOW |
Async e-sign pipeline (Spec 5H). |
| Durable Objects | INSPECTION_PRESENCE, TENANT_PRESENCE |
Live presence for the editor. |
npm run setup:cloudflare provisions every binding listed above and writes their real IDs into a gitignored wrangler.local.jsonc (bootstrapped from the committed placeholder wrangler.jsonc).
IMAGESis optional. The committedwrangler.jsoncdeclares it (an account-scoped, name-only binding). If your account has Images → Transformations enabled (the Free plan includes 5,000 transformations/month), Word exports embed downscaled ~960px JPEGs. If it is not enabled, the deploy still succeeds,env.IMAGESis simply unset, and the Word-export consumer embeds originals up to a hard byte budget (photos beyond it are omitted) so a large report never exceeds the isolate memory limit.
| Secret | When required |
|---|---|
JWT_SECRET |
Always — must be >= 32 random characters. |
SETUP_CODE |
First-run setup only — any value >= 6 characters; gates /setup (fail-closed if unset). |
RESEND_API_KEY |
Optional, only if you want outbound email. |
SENDER_EMAIL |
Required when RESEND_API_KEY is set. |
GEMINI_API_KEY |
Optional — read by the Advanced-settings "Test connection" diagnostic. AI features themselves run on the tenant's own key stored via Settings → Advanced → AI (or, in saas mode only, AI_MANAGED_API_KEY). |
AI_MODEL |
Required for any AI feature — the model id every AI call uses. There is no compiled-in default; unset means AI fails closed with a 503. |
AI_MANAGED_API_KEY |
Optional, saas only — a deployment-provided AI key for tenants the deployment grants managed access to. A standalone deploy has no managed path and ignores it. |
TURNSTILE_SECRET_KEY |
Optional but recommended for the public booking page. |
Set them via wrangler secret put SECRET_NAME or through the Cloudflare dashboard.
npm install
npm run setup:cloudflare # provisions D1/KV/R2 + writes real IDs to wrangler.local.jsonc
npm run deploy # build + wrangler deploy (uses wrangler.local.jsonc)npm run deploy runs react-router build (bundling server/ API + app/ SSR into one worker) then wrangler deploy against the built build/server/wrangler.json, and finally jwt:ensure + setup-code:ensure (provision missing secrets). The build bakes whichever wrangler config wins (WRANGLER_CONFIG env > wrangler.local.jsonc > committed wrangler.jsonc). Apply remote D1 migrations with npm run db:migrate:remote.
One-click: the committed
wrangler.jsonccarries placeholder IDs; the README's Deploy to Cloudflare button provisions resources and injects real IDs automatically — no manualsetup:cloudflareneeded for that path.
First-run /setup is gated solely on the SETUP_CODE secret — the server reads c.env.SETUP_CODE and refuses to proceed when it is unset, so an unprotected Worker can't be claimed. It is any value >= 6 characters (compared for exact equality — no digit/charset constraint). You get one of two ways depending on how you deployed:
- CLI (
npm run deploy): the finalsetup-code:ensurestep (scripts/ensure-setup-code.mjs) generates a randomSETUP_CODEand prints it in the deploy output — but only when the secret is MISSING. It never overwrites an existing value, so re-deploys keep your code. Provide your own first withwrangler secret put SETUP_CODEif you prefer. - One-click: the wizard reads
.dev.vars.exampleand surfacesSETUP_CODEas a secret field you fill in during deploy.
Then visit https://<your-worker>.workers.dev/setup and enter that value to bootstrap your first admin account.
The Worker entry at workers/app.ts is a Hono app. It routes API-owned paths (/api/*, /status, /sign/*, …) to the API app (server/) in-process, and sends every other path to React Router via createRequestHandler with import("virtual:react-router/server-build"), passing { cloudflare: { env, ctx } } as the AppLoadContext. Before delegating to SSR it injects an in-process API_WORKER self-binding so React Router loaders/actions call the API app directly — no network hop, no second worker. @cloudflare/vite-plugin integrates the React Router SSR build with wrangler, so the standard wrangler deploy pipeline ships everything.
npm run dev:hmr # Vite dev server with HMR (react-router dev, port 5173) — fast iteration
npm run dev # build-based: react-router build + wrangler dev (one worker, port 8788)npm run dev:hmr is the everyday loop: instant hot updates for app/ edits, and server/ changes load through the worker entry's lazy API import. npm run dev is build-based (no HMR) and runs the real bundled worker on workerd — use it to verify production-shape behavior. Apply local D1 migrations first with npm run db:migrate. Note for contributors touching workers/app.ts: the entry must keep its top-level import graph tiny (the API is dynamically imported) — a static server import breaks the Vite dev runtime's export-type evaluation.
OpenInspection is designed as a long-term evidence archive for inspectors — there is no automatic deletion of inspection data. Physical deletion happens only when an operator/integration explicitly purges a tenant (see Tenant offboarding below). To keep long-tail storage cheap without losing data, configure an R2 lifecycle rule on the PHOTOS bucket that transitions objects to the Infrequent Access storage class once they age past 365 days. This is a deploy-time/dashboard operation — no application code is involved.
- Effect: objects ≥ 365 days old move to Infrequent Access (~⅓ the storage cost). Reads still work transparently (a per-GB retrieval fee applies, latency is unchanged).
- No expiry rule: do NOT add a delete/expiry lifecycle action — that would erase evidence. Only the transition action is wanted.
- D1 is untouched: D1 has no storage classes and the row volume is small, so no lifecycle is needed there.
Configure it once per environment:
# Dashboard: R2 → PHOTOS bucket → Settings → Object lifecycle rules → Add rule
# Action: "Transition to Infrequent Access", Age: 365 days, scope: whole bucket.
# Or via Wrangler:
wrangler r2 bucket lifecycle add PHOTOS ia-after-365d \
--ia-transition-days 365A hosted/managed deployment runs the same rule on its own
PHOTOSbucket. Self-hosters who do not want Infrequent Access can simply skip this step — it is a cost optimization, not a correctness requirement.
When a tenant is offboarded, the platform first builds a full data export ZIP (CSV/JSON of inspections, templates and agreements plus the photo bytes themselves under photos/, size-bounded so large tenants stay within Worker memory limits; any object beyond the budget is listed in photos-manifest.json with included: false), then purges all tenant rows, R2 objects and KV keys. The purge writes a durable, non-personal destruction record (tenant_destruction_records: tenant id, row/object/byte counts, timestamp) that intentionally has no foreign key to tenants so it survives the deletion as compliance proof. These run via the integration endpoints POST /api/integration/tenants/:slug/data-export and .../purge.