Cloudflare deployment
Cloudflare deployment
Pages (frontend) + Workers (API) + Hyperdrive (external PostgreSQL). Config lives in deploy/cloudflare. Reference: ../reference/deployment-sources.md.
Production hostnames
| Surface | URL |
|---|---|
| Web | https://opentrip.im |
| Docs | https://docs.opentrip.im |
| API | https://api.opentrip.im |
The SPA bakes API_BASE_URL (as Vite BASE_URL) at build time. The Worker
uses the same origin for Better Auth. TRUSTED_ORIGINS lists the Pages
origins plus opentrip:// for native.
Continuous deployment (git push)
Pushing to main triggers
.github/workflows/deploy-cloudflare.yml:
- Build and deploy the SPA to Pages (
opentrip-web→opentrip.im), bakingCAPTCHA_PROVIDER+TURNSTILE_SITE_KEYwhen set. - Build and deploy the static Fumadocs site to its own Pages project
(
opentrip-docs→docs.opentrip.im). - Deploy the API Worker (
opentrip-api→api.opentrip.im), overlaying GitHub Actions variables ontowrangler.api.jsoncvars. The committed config deploys both realtime and authentication rate-limit Durable Objects. - Sync GitHub secrets into Worker secrets (
sync-secrets.mjs).
Source of truth for production config is GitHub Actions (Settings →
Secrets and variables → Actions). Committed wrangler.api.jsonc vars are
local/manual defaults only.
GitHub secrets
| Secret | Required | Notes |
|---|---|---|
CLOUDFLARE_API_TOKEN | yes | Workers + Pages + DNS + Hyperdrive + R2 |
CLOUDFLARE_ACCOUNT_ID | yes | Account id |
HYPERDRIVE_ID | for API | Cached Hyperdrive id (deploy inject; never commit) |
HYPERDRIVE_CACHE_DISABLED_ID | for API | Cache-disabled Hyperdrive for consistency-critical repositories |
DATABASE_URL | for migrate | Origin Postgres URL (CI only, not Worker runtime) |
BETTER_AUTH_SECRET | for API | ≥ 32 chars |
TURNSTILE_SITE_KEY | captcha | Public site key baked into the SPA |
CAPTCHA_SECRET_KEY | captcha | Worker-only; pair with var CAPTCHA_PROVIDER |
RESEND_API_KEY | email OTP | Required when var EMAIL_PROVIDER=resend |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | optional | Google sign-in |
AI_API_KEY | optional | Trip agent |
MAPILLARY_ACCESS_TOKEN | optional | Required when street view uses Mapillary |
OPENWEATHERMAP_API_KEY | optional | Weather proxy |
GOOGLE_MAPS_API_KEY | optional | When GEO_PROVIDER=google |
UNSPLASH_ACCESS_KEY | optional | Trip cover on create; omit → SVG placeholder |
GitHub variables
See deploy/cloudflare/vars.example.json for the full list. Production must set at least:
| Variable | Example |
|---|---|
API_BASE_URL | https://api.opentrip.im |
DOCS_CUSTOM_DOMAIN / DOCS_ZONE | Docs hostname and its Cloudflare zone |
TRUSTED_ORIGINS | https://opentrip.im,…,opentrip:// |
EMAIL_PROVIDER | resend |
EMAIL_FROM | OpenTrip <noreply@opentrip.im> |
CAPTCHA_PROVIDER | cloudflare-turnstile |
DATABASE_PROVIDER | postgres |
STORAGE_BACKEND / R2_BUCKET_NAME | r2 plus the bucket name; deploy injects a native binding |
AI_* / GEO_* | Agent and geo |
Manual re-run: Actions → Deploy Cloudflare → Run workflow.
Prerequisites
wranglerv4+ (npx wrangleris fine).- An external PostgreSQL reachable from Cloudflare (for Hyperdrive).
- Cloudflare zone for
opentrip.im(already active on the deploy account).
1. Database connection (Worker)
A. Hyperdrive (recommended — PlanetScale Postgres)
- Create two Hyperdrive configs against the same Postgres origin:
opentrip-db— query caching enabled, reserved for explicitly stale-tolerant read models.opentrip-db-fresh—--caching-disabled(Trip/member state, authorization, invites, preferences, auth, and agent sessions).
- Store the ids as GitHub secrets (never commit):
gh secret set HYPERDRIVE_ID -R stvlynn/OpenTrip
gh secret set HYPERDRIVE_CACHE_DISABLED_ID -R stvlynn/OpenTrip- On deploy,
deploy-api.mjsinjects both bindings from those env vars into a temporary wrangler config (not checked into git).
Committed fallback in wrangler.api.jsonc: DATABASE_PROVIDER=postgres
(overridden by Actions var when set).
For migrations, also keep origin DATABASE_URL (PlanetScale connection string)
as a GitHub secret; CI runs prisma migrate deploy before Worker deploy.
B. Direct DATABASE_URL (fallback)
If HYPERDRIVE_ID is unset, the Worker uses secret DATABASE_URL instead.
Set DATABASE_PROVIDER / DATABASE_SSL for MySQL direct connect as needed.
Worker prefers Hyperdrive when the binding is present.
When the cached Hyperdrive binding is present, the Worker requires the fresh binding and returns 503 if it is missing. It never silently routes consistency-critical repositories through the cached binding. Cache-disabled Hyperdrive still provides managed connection pooling; only query-result caching is disabled.
2. Migrate + seed / one-shot deploy init
Migrations on every deploy (best practice)
CI always runs before Worker deploy:
prisma migrate deploy # uses GitHub secret DATABASE_URL (origin)
→ wrangler deploy # Worker uses Hyperdrive at runtime| Secret | Role |
|---|---|
HYPERDRIVE_ID | Inject Hyperdrive binding for the Worker |
HYPERDRIVE_CACHE_DISABLED_ID | Inject cache-disabled Hyperdrive for auth/agent |
DATABASE_URL | Origin Postgres URL only for CI migrate/seed |
prisma migrate deploy is idempotent: if there are no new migration folders,
it is a no-op. New schema changes: write a migration in a PR
(make db-migrate-dev locally against a dev DB), merge to main, CI applies
it to production automatically.
Optional:
- Workflow input skip_migrate — emergency skip
- Variable
SKIP_DB_MIGRATE=true— disable migrate until removed - Workflow input seed_db / variable
DB_INIT_SEED=true— demo seed (rare in prod)
Manual migrate + seed (local / break-glass)
# Postgres (same as CI)
DATABASE_URL="postgres://…" pnpm db:migrate
DATABASE_URL="postgres://…" pnpm db:seed
# MySQL (legacy / alternate)
DATABASE_PROVIDER=mysql DATABASE_URL="mysql://…" \
pnpm --filter @opentrip/api db:mysql-init3. API (Workers)
export CLOUDFLARE_API_TOKEN=…
export CLOUDFLARE_ACCOUNT_ID=<CLOUDFLARE_ACCOUNT_ID>
# One-time or when secrets change (also runs in CI after API deploy):
node deploy/cloudflare/scripts/sync-secrets.mjs
# Optional: overlay the same vars CI uses
export API_BASE_URL=https://api.opentrip.im
export EMAIL_PROVIDER=resend
export CAPTCHA_PROVIDER=cloudflare-turnstile
# …
node deploy/cloudflare/scripts/deploy-api.mjswrangler.api.jsonc sets compatibility_flags: ["nodejs_compat"],
observability, custom domain api.opentrip.im, and fallback non-secret
vars. Production values come from GitHub Actions variables via
deploy-api.mjs.
API cache boundary
The default API Worker entrypoint must keep Workers Caching disabled:
"cache": { "enabled": false }Workers Caching is an execution-level response cache in front of the Worker;
it is unrelated to the Cache API exposed as caches.default. Enabling it on
the authenticated gateway can cache any 200 response without an explicit
Cache-Control header for Cloudflare's default TTL. Its cache key does not
partition browser sessions by Cookie, so /api/auth/get-session, /api/trips,
and every other user-dependent GET must never pass through it.
Hono also applies Cache-Control: private, no-store to responses that do not
declare a policy. This is defense in depth for browsers and proxies, not a
reason to re-enable entrypoint caching. Intentionally public immutable uploads
and private short-lived previews keep their explicit route headers.
The street-view provider cache remains enabled through explicit
caches.default.match / put calls using internal synthetic keys. Adding a
Cache API adapter does not require changing Wrangler's cache.enabled.
See the 2026-07-14 incident note.
Workers Logs are retained at 100%, platform traces at 10%, and Sentry samples trip-agent routes at 100%. Configure the Sentry Actions values and use the shared request/turn identifiers described in observability.md.
Wrangler exposes only live tailing. Historical queries use the Workers Observability Telemetry Query API through the repository command:
cp deploy/cloudflare/observability.example.env .env.observability
set -a; source .env.observability; set +a
pnpm logs:cf -- --request-id <application-request-id> --since 1h
pnpm logs:cf -- --live --contains <request-or-turn-id>Create a dedicated token with Workers Observability Write; do not reuse or
sync it as CLOUDFLARE_API_TOKEN. See observability.md.
The Worker has two independent Durable Object namespaces:
| Binding | Class | Purpose |
|---|---|---|
TRIP_REALTIME | TripRealtimeObject | Per-trip realtime collaboration |
AUTH_RATE_LIMIT | AuthRateLimitObject | Globally atomic Better Auth limits per hashed IP/path key |
Product behavior and the WebSocket message protocol are documented in ../backend/realtime.md.
v2-auth-rate-limit creates the authentication limiter class after the
existing realtime migration. No KV, PostgreSQL rate-limit table, or Workers
Rate Limiting binding participates in authentication enforcement. Cloudflare's
native Rate Limiting binding is PoP-local; the Durable Object is deliberately
used for a single global decision per key.
4. Frontend (Pages)
export CLOUDFLARE_API_TOKEN=…
export CLOUDFLARE_ACCOUNT_ID=<CLOUDFLARE_ACCOUNT_ID>
export API_BASE_URL=https://api.opentrip.im
export CAPTCHA_PROVIDER=cloudflare-turnstile
export TURNSTILE_SITE_KEY=… # public site key
node deploy/cloudflare/scripts/deploy-web.mjsSee deploy/cloudflare/pages.md. The build
ships _redirects (SPA fallback) and _headers (service worker + manifest
no-cache, immutable hashed assets) from apps/web/public/.
Secrets and variables
- Secrets — key names in
deploy/cloudflare/secrets.example.json.
Prefer GitHub secrets + CI sync; locally
sync-secrets.mjsorwrangler secret put. - Variables — key names in
deploy/cloudflare/vars.example.json.
Prefer GitHub Actions variables;
deploy-api.mjsoverlays them at deploy.
R2 uses the native R2_FILE_STORAGE Worker binding injected from
R2_BUCKET_NAME. Same-account Workers must not receive S3 access-key secrets;
the S3-compatible adapter remains available only for non-Worker runtimes or
cross-account interoperability.
Captcha: CAPTCHA_PROVIDER must be cloudflare-turnstile; public
TURNSTILE_SITE_KEY (secret in GitHub only so it is not
committed; still safe to bake into the SPA) + Worker CAPTCHA_SECRET_KEY.
Email OTP: EMAIL_PROVIDER=resend + EMAIL_FROM + RESEND_API_KEY.
To enable the trip agent (see ../backend/agent.md), set
AI_API_KEY as a secret and AI_PROVIDER, AI_MODEL, AI_BASE_URL, and the
threshold vars as Actions variables. Without AI_MODEL + AI_API_KEY the
agent routes respond 404 and the frontend hides the entry point.
To enable street view, set MAPILLARY_ACCESS_TOKEN as a secret and
STREET_VIEW_PROVIDER=mapillary as an Actions variable. The agent may inspect
bounded ordinary static previews; panorama bytes are rejected by the
application and remain browser-only. See
../backend/street-view.md.
Geo agent tools default to OSM (GEO_PROVIDER=osm). To use Google Places +
Routes instead, set GEO_PROVIDER=google and GOOGLE_MAPS_API_KEY. See
../backend/geo.md.
Airbnb lodging tools (airbnbSearch, airbnbListingDetails) need no API key.
Optional vars: LODGING_IGNORE_ROBOTS_TXT, LODGING_DISABLE_GEOCODING,
LODGING_TIMEOUT_MS, LODGING_GEOCODE_USER_AGENT. See
../backend/lodging.md.
Troubleshooting
Browser shows “blocked by CORS” on api.opentrip.im
CORS is configured from TRUSTED_ORIGINS and is correct for
https://opentrip.im. When the Worker hangs or throws before a response
(Cloudflare error 1101), the edge returns a plain error page without
CORS headers — Chrome reports that as a CORS failure.
Check Workers Logs for:
Worker's code had hung and would never generate a response$workers.outcome = exceptionon/api/auth/*
Mitigations in code: per-request shared pg.Pool (do not cache across
Worker isolate freezes), connection timeouts, no session preload on
/api/auth/*, and emergency CORS on uncaught Worker errors. Hyperdrive still
pools origin TCP at the edge.
Sign-up shows no captcha / no OTP step
- Confirm Actions vars
CAPTCHA_PROVIDERand secretsTURNSTILE_SITE_KEY/CAPTCHA_SECRET_KEYare set, and the latest Pages + Worker deploy ran. - Confirm
EMAIL_PROVIDER=resend,EMAIL_FROM, andRESEND_API_KEY. - OTP UI lives in
AuthFormafter a successfulsign-up/email. Gate must not remount on session refetch while logged out (initialisPendingonly).
Hyperdrive read-after-write
Hyperdrive caches eligible SELECT responses (default max_age 60s) and
does not invalidate that cache when the Worker writes to the origin. A
matching SELECT right after an INSERT/UPDATE can therefore return a
stale row until max_age expires (or during stale_while_revalidate).
Local Docker does not use Hyperdrive — write-then-refetch bugs often pass
make dev and only show on opentrip.im (or any Worker + Hyperdrive deploy).
SPA conventions and checklists: ../frontend/data-caching.md. ADR: ../decisions/0006-mutation-echo-over-refetch.md.
Application rules
- Mutation responses must not re-
SELECTthe row just written. Repository update methods return the written domain snapshot (e.g.SqlUserPreferenceRepository.updateAgentPanel). Echoing a post-writefindByUserIdinto the HTTP body caused the agent panel to snap shut after open: optimisticcollapsed: falsewas overwritten by a cachedtrue. AgentPOST …/messagesreturns the insertedmessageDTO so the SPA cansetQueryDatawithout an immediate list GET. - Classify reads by consistency. Business state and authorization use the cache-disabled binding; query caching is reserved for a separately named, stale-tolerant read model.
- Use the cache-disabled Hyperdrive binding (
HYPERDRIVE_CACHE_DISABLED) for Trip/member state, permissions, invites, preferences, Better Auth, and agent sessions. The Worker refuses a cached-only Hyperdrive deployment rather than silently weakening consistency. - Trip create follows the same echo pattern:
POST /api/tripsreturns the fullTripDto; the SPAsetQueryDatas the list + detail caches and opens the planner instead of refetchingGET /api/trips.
Anti-patterns (will bite in prod)
| Anti-pattern | What happens |
|---|---|
useMutation({ onSuccess: () => invalidateQueries(trips) }) after create | Stale GET /api/trips overwrites cache; new trip missing ~60s |
invalidateQueries(trip) after agent stream settle or agent events poll | Stale GET /api/trips/:id wipes a just-echoed stop insert (prod only) |
Repository update then findById for the HTTP body | Client receives pre-write values from SELECT cache |
Agent patchQueue calling findById / loadEditable between each write tool | Later tool echoes carry sibling days/stops from a stale SELECT; SPA last-wins looks like Day 1 “rolled back” |
| “Fix” by turning off Hyperdrive query cache | Higher origin load; hides the real contract bug |
QA only on make dev for create → list flows | False confidence; Hyperdrive never in the path |
How to verify a suspected stale list
- Network:
POSTreturns201with the newid. - Immediate
GET /api/tripsomits thatid(or returns old fields). - Direct
GET /api/trips/:idis200(row exists; list/cache path is wrong). - Same flow on local Docker shows the new row immediately.
Cloudflare reference: Query caching — read-after-write.
See deploy/cloudflare/hyperdrive.md for create/bind steps.
Rollback
npx wrangler rollback --config deploy/cloudflare/wrangler.api.jsoncPages: redeploy a previous build, or promote a prior deployment in the dashboard.