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. Principles
Section titled “1. Principles”- 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 readsPUBLIC_APP_URLetc. fromplatform.envat runtime; it uses zero$env/static/public. The only build-time-inlined value isVITE_GIT_SHA, which should be identical across a promoted artifact.) - 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.mjsrenders them. Workers do support[env.*], so cron/email use native env blocks. - 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).
- Each non-prod env earns its existence by purpose, not by cloning prod. dev = integration; demo = reproducible showcase; uat = prod-mirror gate.
- uat is the single mandatory pre-prod gate. Migrations and release candidates land on uat (with prod-shaped data) before prod.
2. Topology
Section titled “2. Topology”| 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 |
3. Config mechanics
Section titled “3. Config mechanics”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.tomlandapps/marketing/wrangler.tomlremain as the hxlab dev config used by localwrangler devand the existing pipeline. They are left untouched until the hxlab redirect retires; the generatedwrangler.dev.tomlis 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. - demo — cattle, 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 fires0 18 * * *). Signups disabled. Implementation:apps/app/src/lib/demo/reset.ts. Hard guard: refuses unlessPUCCHA_ENV=demo. The KB “seeded once” had no working remote path until ADR-0159 —seed-puccha.tsis local-only and can’t create Vectorize embeddings. demo now carries thedemo-kbvalidation tenant (provisioned per that ADR; canary:pnpm check:demo-kb), so off-prod chat/inbox/e2e validation no longer needs prod. - uat — prod-mirror.
scripts/uat-snapshot.shexports prod D1, imports into uat, then appliesscripts/uat-anonymize.sqlto overwrite every PII column (users, visitors, message bodies, identify secrets). Gives prod-shaped volumes/relationships/edge-cases with no real PII. - prod — live.
5. Promotion + CI (enforced, gated)
Section titled “5. Promotion + CI (enforced, gated)”.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─▶ doneworkflow_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 thepuccha.aizone’s Bot Fight Mode, which 403s CI runner IPs.*.pages.devis 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 theproductionGitHub 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.shretries 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 secretsVITE_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.
PR previews (ADR-0108)
Section titled “PR previews (ADR-0108)”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.
6. Stand-up runbook (per env)
Section titled “6. Stand-up runbook (per env)”cd platform# 1. Provision resources (creates D1/R2/KV/Vectorize/Pages, writes IDs to manifest)pnpm provision:<env> # needs wrangler authed to 25b5fba8… + jqpnpm gen:wrangler # fold the new IDs into wrangler.<env>.toml# 2. Schemapnpm 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.
7. Secrets — deviation from ADR-0041 D5
Section titled “7. Secrets — deviation from ADR-0041 D5”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).
8. Shipped (2026-06-03)
Section titled “8. Shipped (2026-06-03)”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)./healthgreen on all; prod passes HSTS/CSP + cross-tenant isolation. - CI deploys via
scripts/deploy.sh(push→dev, release→prod, dispatch→any), validated green. GH secretsCLOUDFLARE_API_TOKEN/ACCOUNT_ID/PRODUCTION_APP_URLpoint at the Puccha account. - wrangler Pages quirks (baked into the scripts):
pages project create/listresolve the account from the user’s membership (wrong for multi-account users) → provision/attach use the REST API.pages deployrejects--config <custom path>and rejectsaccount_idin Pages config →deploy.shstageswrangler.<env>.tomlaswrangler.tomland passes--project-name. - apex 308→www: done via
puccha-redirect-prodWorker (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, prodnoreply@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). TheEmail Sending: Sendtoken perm sends but cannot manage domains — that step is dashboard-only. - cron: the
puccha-cron-<env>Worker is the scheduler; the oldcron-triggers.ymlGH workflow was retired.
9. Known follow-ups
Section titled “9. Known follow-ups”- Sentry server-side env tag. Client Sentry derives
environmentfrom the app hostname (covers all four envs in-browser). Server-sideinitSentry()runs at module load, beforeplatform.envis readable, so server events fall back toproduction. Add a per-requestpuccha_envtag inhooks.server.ts. - Email send-token scope-down. The app currently sends with the broad
provision token (it has
Email Sending: Sendadded). Move to a dedicated send-only token in.env.prodto 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.ymlneeds a backup R2 bucket +D1_PROD_NAME/R2_BACKUP_BUCKETsecrets to run (ADR-0053). - Demo seed. Demo env is empty; seed the showcase tenants + ingest content.
- hxlab teardown.
*.hxlab.iodev stays live as a redirect; retire per ADR-0041 D9. Legacy hosts still recognized inPUCCHA_APP_HOSTS/PUCCHA_HELP_HOSTS. - Contact emails.
legal@hxlab.io/privacy@hxlab.io/sales@hxlab.ioin 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).