Skip to content

Puccha Environments — Design

Status: Accepted · Date: 2026-06-03 · Supersedes the dev/prod-only sketch in ADR-0041 (which this doc extends to a four-environment topology).

This is the source-of-truth design for how Puccha runs across dev / demo / uat / prod plus marketing, all in the dedicated Cloudflare account 25b5fba80388bf85bd9adb9001458c5e under puccha.ai.

The machine-readable counterpart is platform/infra/environments.json — edit that, run pnpm gen:wrangler, and the per-env wrangler configs regenerate.


  1. Build once, promote many. The app is built a single time; the identical artifact is deployed to dev → uat → prod. Nothing environment-specific is inlined at build time — every per-env value is a runtime wrangler [var] or a secret. (Verified: the app reads PUBLIC_APP_URL etc. from platform.env at runtime; it uses zero $env/static/public. The only build-time-inlined value is VITE_GIT_SHA, which should be identical across a promoted artifact.)
  2. Config as data + generator. Cloudflare Pages can’t express [env.*] blocks, so four envs would mean four hand-maintained wrangler files — a drift trap. Instead one manifest + gen-wrangler.mjs renders them. Workers do support [env.*], so cron/email use native env blocks.
  3. Full resource isolation per env. D1/R2/KV/Vectorize are account-scoped, single-tenant resources; sharing across envs defeats isolation, blast-radius, and billing separation. One of each per env (ADR-0041 D3).
  4. Each non-prod env earns its existence by purpose, not by cloning prod. dev = integration; demo = reproducible showcase; uat = prod-mirror gate.
  5. uat is the single mandatory pre-prod gate. Migrations and release candidates land on uat (with prod-shaped data) before prod.

Env Purpose Build Data strategy Deploy trigger
PR preview per-PR ephemeral QA per-PR none / seed automatic (Pages preview)
dev engineering integration surface promoted artifact synthetic (disposable) push to main
demo sales / onboarding showcase promoted artifact seed + nightly reset; signups off mirrored after prod by the local autodeploy daemon (best-effort, never gates prod — #477); workflow_dispatch on Actions
uat acceptance + pre-prod gate promoted artifact anonymized prod snapshot workflow_dispatch (gated promote)
prod live customers same artifact as uat live GitHub Release published
marketing public Astro site per-target (Astro inlines canonical) content prod on release; dev preview on push

Domains — flat / single-level (keeps free *.puccha.ai Universal SSL)

Section titled “Domains — flat / single-level (keeps free *.puccha.ai Universal SSL)”
Host Surface
puccha.ai (apex) → 308 www · www.puccha.ai Marketing (prod)
app.puccha.ai · help.puccha.ai · support+*@puccha.ai prod
app-uat.puccha.ai · help-uat.puccha.ai · support+*@uat.puccha.ai uat
app-demo.puccha.ai · help-demo.puccha.ai · support+*@demo.puccha.ai demo
app-dev.puccha.ai · help-dev.puccha.ai · support+*@dev.puccha.ai dev
dev.puccha.ai Marketing dev preview

Nesting (app.dev.puccha.ai) is avoided: it would force paid Advanced Certificate Manager. Flat also slots a future staging in cleanly.

Resource naming — puccha-<service>-<env>

Section titled “Resource naming — puccha-<service>-<env>”
Type dev demo uat prod
Pages — app puccha-app-dev puccha-app-demo puccha-app-uat puccha-app-prod
Worker — marketing puccha-portal-dev puccha-portal-prod
Worker — cron puccha-cron-dev puccha-cron-demo puccha-cron-uat puccha-cron-prod
Worker — email puccha-email-dev puccha-email-demo puccha-email-uat puccha-email-prod
D1 puccha-db-dev puccha-db-demo puccha-db-uat puccha-db-prod
R2 puccha-uploads-dev puccha-uploads-prod
KV puccha-sessions-dev puccha-sessions-prod
Vectorize puccha-chunks-dev puccha-chunks-prod
AE dataset puccha_metrics_dev puccha_metrics_prod

platform/infra/environments.json # SINGLE SOURCE OF TRUTH (hosts, IDs, flags)
platform/scripts/gen-wrangler.mjs # renders per-env wrangler files (+ --check CI guard)
platform/apps/app/wrangler.<env>.toml # GENERATED (Pages, one per env)
platform/apps/marketing/wrangler.<env>.toml # GENERATED (dev + prod only)
platform/apps/cron-worker/wrangler.toml # hand-written, native [env.*]
platform/apps/email-worker/wrangler.toml # hand-written, native [env.*]

Per-env runtime [vars] the generator emits onto the app: PUCCHA_ENV, PUBLIC_APP_URL, PUBLIC_MARKETING_URL, PUBLIC_HELP_HOST, PUBLIC_EMAIL_DOMAIN, PUCCHA_DATA_STRATEGY, PUCCHA_SIGNUPS_ENABLED, PUCCHA_STRIPE_MODE. These drive runtime behavior (Sentry environment, signup gating, Stripe mode, email From/inbound domain) without a rebuild.

Legacy note: apps/app/wrangler.toml and apps/marketing/wrangler.toml remain as the hxlab dev config used by local wrangler dev and the existing pipeline. They are left untouched until the hxlab redirect retires; the generated wrangler.dev.toml is the new-account dev config.


4. Data strategy (the part that makes non-prod real)

Section titled “4. Data strategy (the part that makes non-prod real)”
  • dev — synthetic seed (pnpm seed:*), wiped freely. No realism requirement.
  • democattle, not a pet. Durable showcase (tenant rows + KB + settings) is seeded once; ephemeral interaction data (conversations, tickets, visitors, query/audit logs) is purged nightly by /api/cron/demo-reset (cron Worker fires 0 18 * * *). Signups disabled. Implementation: apps/app/src/lib/demo/reset.ts. Hard guard: refuses unless PUCCHA_ENV=demo. The KB “seeded once” had no working remote path until ADR-0159seed-puccha.ts is local-only and can’t create Vectorize embeddings. demo now carries the demo-kb validation tenant (provisioned per that ADR; canary: pnpm check:demo-kb), so off-prod chat/inbox/e2e validation no longer needs prod.
  • uatprod-mirror. scripts/uat-snapshot.sh exports prod D1, imports into uat, then applies scripts/uat-anonymize.sql to overwrite every PII column (users, visitors, message bodies, identify secrets). Gives prod-shaped volumes/relationships/edge-cases with no real PII.
  • prod — live.

.github/workflows/deploy.yml is a gated promotion pipeline — a stage can’t advance past a red smoke, so dev→uat→prod is enforced, not just intended (no direct-to-prod):

push to main → DEV ─smoke─▶ UAT ─smoke─▶ (stop; cut a release for prod)
release published → smoke UAT (gate) ─▶ PROD ─smoke─▶ done
workflow_dispatch → single env (break-glass / demo refresh) ─smoke─▶ done
  • The gate is scripts/smoke.sh <env>: health (all bindings), root /, cross-tenant isolation, auth-methods, MFA-config. A non-zero exit fails the smoke job and halts promotion. Prod is doubly gated: it deploys only on a published release (the human act = the approval) AND only if uat smoke is green right now.
  • Smoke targets puccha-app-<env>.pages.dev, not the custom domain — the custom domains sit behind the puccha.ai zone’s Bot Fight Mode, which 403s CI runner IPs. *.pages.dev is outside the zone. Custom-domain-only checks (HSTS, marketing, apex 308) are verified at deploy + manually, not in the CI gate.
  • DRY: build/deploy is the reusable _deploy-env.yml; the gate is _smoke.yml. Prod routes through the production GitHub Environment (add a required-reviewer rule there for a hard approval gate once on a paid plan; today the gate is the release trigger + uat smoke).
  • Resilience: deploy.sh retries the Pages upload 3× — CF Pages intermittently returns “Failed to publish your Function. Unknown internal error” and succeeds on retry.
  • Auth: repo secrets CLOUDFLARE_API_TOKEN/ACCOUNT_ID, plus build secrets VITE_SENTRY_DSN + SENTRY_AUTH_TOKEN.

Migrations are forward-only and follow the same chain: pnpm migrate:dev:uat (verify) → :prod. Same for secrets (secrets:push:<env> in order). Direct-to-prod is reserved for a genuine SEV1 restore — then backfill uat.

Orthogonal to the promotion chain: every PR to main gets a live, fully-functional preview at https://pr-<N>.puccha-app-dev.pages.dev via .github/workflows/preview.yml → builds the PR head, deploys to the dev project’s preview environment, and upserts a sticky comment with the URL. Previews share dev’s D1/KV/R2/Vectorize (disposable, never prod data) and are isolated from dev→uat→prod — a broken preview can’t promote. The preview env was seeded once with dev’s secrets+bindings via pnpm preview:setup (scripts/setup-preview-env.mjs) — re-run it after rotating dev secrets.


Terminal window
cd platform
# 1. Provision resources (creates D1/R2/KV/Vectorize/Pages, writes IDs to manifest)
pnpm provision:<env> # needs wrangler authed to 25b5fba8… + jq
pnpm gen:wrangler # fold the new IDs into wrangler.<env>.toml
# 2. Schema
pnpm migrate:<env>
# 3. Secrets (cp .env.example .env.<env>, fill values first)
pnpm secrets:push:<env>
# 4. Deploy (build-once locally; CI does this on trigger)
pnpm deploy:<env>
# 5. Dashboard-only (no API): attach custom domains, WAF rate rules,
# Email Routing (support+*@<domain> → puccha-email-<env>), AI Gateway.
# 6. Smoke test — see docs/LAUNCH.md § smoke tests.

Order across envs: dev → demo → uat → prod. Prod only after uat soaks clean.


ADR-0041 mandated 1Password as the secret source of truth. This design uses the Cloudflare-native path instead (scripts/secrets-push.sh <env> pushes from a gitignored .env.<env>): secrets never enter git, are generated fresh per env, and live in CF’s own secret store + the operator’s password manager. Rationale: no op CLI / vault dependency for a ~15-secret launch; revisit 1Password + a GH Service Account if the team grows. Secrets are still regenerated per env, never carried across (ADR-0041 D6 holds).


All four envs provisioned + deployed on puccha.ai (account 25b5fba8…) and verified. Notes for whoever comes next:

  • Live: app.puccha.ai / puccha.ai (308→www) / www.puccha.ai (prod), app-{uat,demo,dev}.puccha.ai, dev.puccha.ai (marketing preview). /health green on all; prod passes HSTS/CSP + cross-tenant isolation.
  • CI deploys via scripts/deploy.sh (push→dev, release→prod, dispatch→any), validated green. GH secrets CLOUDFLARE_API_TOKEN/ACCOUNT_ID/PRODUCTION_APP_URL point at the Puccha account.
  • wrangler Pages quirks (baked into the scripts): pages project create/list resolve the account from the user’s membership (wrong for multi-account users) → provision/attach use the REST API. pages deploy rejects --config <custom path> and rejects account_id in Pages config → deploy.sh stages wrangler.<env>.toml as wrangler.toml and passes --project-name.
  • apex 308→www: done via puccha-redirect-prod Worker (token lacked the Dynamic-Redirect ruleset perm). Swap to a native Single Redirect rule if that perm is ever added.
  • Email (CF Email Sending, ADR-0042) is live on all four envs, each sending from its own subdomain (noreply@{env}.puccha.ai, prod noreply@puccha.ai). Each subdomain must be onboarded separately in CF → Email → Email Sending (CF generates per-domain DKIM; DNS auto-creates since the zone is in-account). The Email Sending: Send token perm sends but cannot manage domains — that step is dashboard-only.
  • cron: the puccha-cron-<env> Worker is the scheduler; the old cron-triggers.yml GH workflow was retired.
  • Sentry server-side env tag. Client Sentry derives environment from the app hostname (covers all four envs in-browser). Server-side initSentry() runs at module load, before platform.env is readable, so server events fall back to production. Add a per-request puccha_env tag in hooks.server.ts.
  • Email send-token scope-down. The app currently sends with the broad provision token (it has Email Sending: Send added). Move to a dedicated send-only token in .env.prod to shrink the app’s blast radius.
  • Prod secrets. Stripe live + webhook, Sentry prod DSN, Google OAuth client (app.puccha.ai/api/auth/callback/google) — empty until launch needs them.
  • DR. d1-backup.yml needs a backup R2 bucket + D1_PROD_NAME/R2_BACKUP_BUCKET secrets to run (ADR-0053).
  • Demo seed. Demo env is empty; seed the showcase tenants + ingest content.
  • hxlab teardown. *.hxlab.io dev stays live as a redirect; retire per ADR-0041 D9. Legacy hosts still recognized in PUCCHA_APP_HOSTS/PUCCHA_HELP_HOSTS.
  • Contact emails. legal@hxlab.io / privacy@hxlab.io / sales@hxlab.io in marketing reference the HX Lab legal entity — left as-is pending a branding call.
  • Token scope-down. Bootstrap tokens are full-account; scope each to its env’s resource IDs after provisioning (ADR-0041 D5).