Deployment
Deployment — Trunk + Tag Based (Cloudflare Workers via GitLab CI)
Section titled “Deployment — Trunk + Tag Based (Cloudflare Workers via GitLab CI)”How code reaches Cloudflare. Single active branch = main (trunk-based).
Deployment is driven by two axes: the trigger (branch push vs release tag)
selects the environment, the changed paths select which Workers deploy.
GitLab CI runs the deploy stage after verify passes.
CI file layout (split via include:local)
Section titled “CI file layout (split via include:local)”The pipeline is split to keep root lean — root holds only globals + shared jobs:
| File | Holds |
|---|---|
.gitlab-ci.yml (root) | globals (image, stages), include:, and app-agnostic jobs: install, verify (typecheck/lint/unit), integration, migrate. |
apps/web/.gitlab-ci.yml | deploy:web + its .changes_web anchor. |
apps/cron-daily/.gitlab-ci.yml | deploy:cron + its .changes_cron anchor. |
⚠️ include merge rules (why the split looks like it does):
- Global keywords (
stages,image,default,workflow) are overridden last-wins, not appended → declare them ONCE in root; app files must NOT redeclare them. - YAML anchors are file-scoped — they do NOT cross
include. Each.changes_*anchor lives in the same file as the job that uses it. needs:works across files (one merged pipeline) →deploy:*canneeds:the root-definedinstall/verify/migratejobs.
Trigger → environment
Section titled “Trigger → environment”| Trigger | Wrangler env | Path filter | Deployed Worker names |
|---|---|---|---|
push to main | development | yes (changes:) | xprivate-web-development, xprivate-cron-daily-development |
release tag vX.Y.Z | production | no (full deploy) | xprivate-web-production, xprivate-cron-daily-production |
| any other branch / MR | — | — | no deploy (verify only) |
main deploys to development automatically on every push (path-precise).
Production ships only by cutting a release tag (git tag v1.2.3 && git push --tags)
— a release always full-deploys (no changes: filter). Worker name =
<base-name>-<env> — cron gets the suffix from wrangler --env; web gets it
baked at build time by @cloudflare/vite-plugin (see Env selection below).
⚠️ Protected-tag gate. CLOUDFLARE_API_TOKEN (and NEON_DATABASE_URL_PROD) are
Protected CI/CD variables — exposed only on protected refs. main is protected
by default; the release tags must be covered by a Protected Tag rule
(Settings → Repository → Protected tags → wildcard v* — already configured). Without
it the prod deploy + prod migrate silently skip (token resolves empty → rule false).
Current status (greenfield): development-only. No release tag has been cut yet, so production has never deployed —
xprivate-web-production/-cron-daily-productiondo not exist in Cloudflare yet. The prod path is wired and gated (protectedv*tag), but stays dormant until the firstgit tag vX.Y.Z. Until then everything ships to thedevelopmentenv on eachmainpush. Do not cut a prod tag until the maintainer declares prod-ready.
Path → app (what triggers a deploy)
Section titled “Path → app (what triggers a deploy)”Deployment is dependency-precise: a Worker redeploys when its own app code OR
any package in its transitive dependency closure changes. Encoded as GitLab CI
rules: changes: lists per deploy job.
| Changed path | Redeploys |
|---|---|
apps/web/** | web |
apps/cron-daily/** | cron-daily |
packages/service/** | web + cron-daily |
packages/auth/** | web + cron-daily |
packages/db/** | web + cron-daily |
root build/config: package.json, bun.lock, tsconfig.json, biome.json | web + cron-daily |
Why every package hits both apps (today)
Section titled “Why every package hits both apps (today)”Transitive dependency closure (see architecture-conventions.md for dep direction):
apps/web → service, auth, db (direct)apps/cron-daily → service, db → service→auth→db ⇒ also auth, dbservice → auth, dbauth → dbdb → (none)Every package is currently in both apps’ closure, so any packages/** change
redeploys both. The mapping is still written per-app (not a blanket “deploy all”)
so a future app that does NOT use a given package keeps a correct, minimal
trigger set. When adding an app or changing a dependency, update the changes:
lists to match the real closure.
Adding a new deployable app
Section titled “Adding a new deployable app”- Add
apps/<new>/wrangler.jsoncwithenv.production+env.development. - Add
apps/<new>/.gitlab-ci.ymlwith adeploy:<new>job + its own.changes_<new>anchor (mirrorapps/web/.gitlab-ci.yml); do NOT redeclare globals there. - Register it in root
.gitlab-ci.ymlunderinclude:(- local: apps/<new>/.gitlab-ci.yml). - Set its
rules: changes:to its own transitive closure — not a copy-paste of every package path.
Wrangler environments
Section titled “Wrangler environments”Both wrangler.jsonc files define env.production and env.development.
⚠️ Bindings are NOT inherited by named environments. Each env block must
re-declare its own hyperdrive, r2_buckets, and any other bindings/vars — a
top-level binding does not flow into env.*. Top-level main,
compatibility_date, compatibility_flags ARE inherited. Verify current
inheritance rules against the wrangler docs before editing.
- Each env points at its own Hyperdrive config (prod DB vs dev DB) and R2 bucket.
- cron-daily: production only carries the
triggers.cronsschedule. Thedevelopmentenv deploys the Worker with no trigger — the daily strike-decay job must not auto-mutate the dev DB. Invoke dev cron manually to test.
Env selection: build-time (web) vs deploy-time (cron)
Section titled “Env selection: build-time (web) vs deploy-time (cron)”The monorepo has two different env-selection mechanisms. Intentional, driven by app type — not a style inconsistency — but it surprises first-time readers, so it is spelled out here.
| App | Type | Env chosen at | How | Deploy command |
|---|---|---|---|---|
| web | TanStack Start (SSR, needs a Vite build) | build time | CLOUDFLARE_ENV=<env> read by @cloudflare/vite-plugin | wrangler deploy (no --env) |
| cron-daily | plain script worker (no build) | deploy time | wrangler --env <env> | wrangler deploy --env <env> |
Why web cannot use --env: @cloudflare/vite-plugin owns the build and must know
the target env to bake env-specific bindings (Hyperdrive ID, R2 bucket name) into the
bundle. It resolves env.<CLOUDFLARE_ENV> from wrangler.jsonc at build time and
emits a flat dist/server/wrangler.json (already named xprivate-web-<env>,
legacy_env: true, with the env.* blocks flattened away), plus a deploy-redirect at
.wrangler/deploy/config.json that points wrangler deploy at the flat config.
Passing --env <env> to that deploy makes wrangler look for an env.<env> block that
no longer exists in the flattened config → error. This is Cloudflare’s sanctioned
pattern for Vite/framework apps on Workers (CLOUDFLARE_ENV=x vite build && wrangler deploy).
Why cron still uses --env: cron-daily has no build step; wrangler deploy reads
apps/cron-daily/wrangler.jsonc directly, which still carries env.* blocks, so the
env is selected at deploy time the conventional way.
Verify-step consequence: wrangler deployments list does not honor the
deploy-redirect config. For web it must be given the worker name explicitly
(--name "xprivate-web-$DEPLOY_ENV"); a bare deployments list in the web job queries
the non-existent top-level name xprivate-web and false-fails the deploy. For cron,
--env "$DEPLOY_ENV" resolves the name from the still-present env block.
Adding a new app: framework/Vite app → follow the web build-time model; plain
worker → follow the cron --env model. Don’t blindly copy --env into a Vite-app
deploy.
CI deploy stage
Section titled “CI deploy stage”.gitlab-ci.yml stage order: install → verify → integration → migrate → deploy.
migratejob: applies Drizzle migrations (idempotent) to the persistent Neon DB before deploy. Trigger-selects the DB —mainpush →NEON_DATABASE_URL_DEV, release tag →NEON_DATABASE_URL_PROD; skipped until the matching var exists.deploy:webanddeploy:cronbothneeds:it asoptional: trueso deploy still runs when the job is absent. ⚠️ Migrations must be expand-contract / backward-compatible — migrate and deploy are not atomic.- Jobs:
deploy:web,deploy:cron. Eachneeds:theinstalljob (for thenode_modulesartifact) plus the verify jobs (typecheck,lint,unit) green before deploying. - Each job’s
rules:match the trigger (→ setDEPLOY_ENV): a release tag →production(nochanges:— always deploys), or amainpush →developmentAND thechanges:paths for that app’s closure. No match → the job does not run. deploy:webrunsCLOUDFLARE_ENV=$DEPLOY_ENV bun run --cwd apps/web build(Vite →dist/), thenwrangler deployfromapps/web— no--env(env baked at build; see Env selection above). Verify:wrangler deployments list --name "xprivate-web-$DEPLOY_ENV".deploy:crondeployssrcdirectly withwrangler deploy --env "$DEPLOY_ENV"(wrangler bundles; no build step). Verify:wrangler deployments list --env "$DEPLOY_ENV".- Gated on
$CLOUDFLARE_API_TOKEN— deploy jobs are skipped until the secret exists, so the pipeline stays green before Cloudflare is wired (same pattern as theintegrationjob gating on$NEON_API_KEY).
Required GitLab CI/CD variables
Section titled “Required GitLab CI/CD variables”| Variable | Value |
|---|---|
CLOUDFLARE_API_TOKEN | Cloudflare API token (permissions below). Mark Masked + Protected. |
CLOUDFLARE_ACCOUNT_ID | target Cloudflare account ID. |
NEON_DATABASE_URL_DEV | Direct (non-Hyperdrive) Neon connection string for the dev DB; drizzle-kit migrate target. Mark Masked + Protected. |
NEON_DATABASE_URL_PROD | Direct connection string for the prod DB; drizzle-kit migrate target. Mark Masked + Protected. |
Set in GitLab → Settings → CI/CD → Variables. Mark protected so only protected
refs can read them: main (protected by default) and the release tags. The release
tags MUST be added as a Protected Tag wildcard v*
(Settings → Repository → Protected tags) — otherwise the Protected CLOUDFLARE_API_TOKEN
NEON_DATABASE_URL_PRODresolve empty on the tag pipeline and the prod deploy/migrate silently skip. The token grants Workers deploy access to the whole account — never commit it.
These four are deploy/migrate infrastructure creds only. They are NOT the app’s runtime secrets.
Runtime secrets — managed by Infisical (not CI, not wrangler secret put)
Section titled “Runtime secrets — managed by Infisical (not CI, not wrangler secret put)”App runtime secrets/config (BETTER_AUTH_*, GOOGLE_CLIENT_*, R2_*, OTEL_*,
BOOTSTRAP_ADMIN_EMAILS, …) are owned by the self-hosted Infisical instance and
pushed into the deployed Worker via Infisical’s Cloudflare Workers native
integration (source path /apps/<app>, e.g. /apps/web). Do NOT set them as
GitLab CI/CD variables and do NOT wrangler secret put them by hand — that drifts
from the source of truth. Local dev pulls the same secrets via
infisical export … > .dev.vars. Full workflow → the infisical-secrets skill.
CLOUDFLARE_API_TOKEN permissions
Section titled “CLOUDFLARE_API_TOKEN permissions”Cloudflare’s GitLab guide recommends the “Edit Cloudflare Workers” token template, but that template does not include Hyperdrive — and both Workers bind Hyperdrive, so it must be added.
| Permission group | Scope | Level | Why |
|---|---|---|---|
| Workers Scripts | Account | Edit | deploy worker code (cron triggers, static assets) |
| Hyperdrive | Account | Edit | deploy worker bound to Hyperdrive + create per-env configs ⚠️ not in template |
| Workers R2 Storage | Account | Edit | R2 binding + create the xprivate-uploads-dev bucket |
| Account Settings | Account | Read | resolve target account |
| User Details | User | Read | token verify / wrangler whoami |
Fastest path: API Tokens → Create → template “Edit Cloudflare Workers” → then add
Account · Hyperdrive · Edit. The template also grants Workers KV Storage (no KV here)
and Workers Routes (Zone). Workers Routes was previously unused, but the dev web env
now uses a custom domain (dev.dashboard.xprivateducation.id, custom_domain: true
in apps/web/wrangler.jsonc env.development), so the zone-scoped permission IS
needed to attach that custom domain — keep it.
⚠️ Custom domain note: the custom domain host is xprivateducation.id, which
differs from the admin OAuth-restricted domain @xprivate.education. Confirm with the
maintainer that this is intentional (two separate domains for hosting vs. auth).
Scoping: Account Resources → Include → this account only. For the custom domain,
also include Zone Resources → Include → Specific zone → xprivateducation.id so
the token can attach the custom domain route and manage DNS on that zone. Skip IP
filtering (GitLab runner IPs are dynamic).
Infra still pending (not blocking the rule)
Section titled “Infra still pending (not blocking the rule)”- Per-env Hyperdrive IDs are filled in (
apps/web/wrangler.jsoncandapps/cron-daily/wrangler.jsoncenv.production→538922a1bdab4ac1b12c7f39019ecbd8,env.development→8f247a9f732348cda8d7eb2a88f6894a). What remains: verify these configs actually exist in the Cloudflare account and point at the correct Neon DB per env (wrangler hyperdrive list). Note:apps/cron-daily/wrangler.jsoncstill has an unused top-level<hyperdrive-config-id>placeholder, but it is harmless — deploy always runs with--env, so the env block’s ID wins and the top-level binding is never selected. - R2 buckets per env must exist (
wrangler r2 bucket create ...). CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDnot yet set → deploy jobs are skipped until added.NEON_DATABASE_URL_DEV/NEON_DATABASE_URL_PRODnot yet set →migratejob is skipped until added; once set, persistent-DB migration is fully automated.
Out of scope
Section titled “Out of scope”sites/*.pages.devare Cloudflare Pages static sites (architecture/flowchart docs) — a different deploy target, not covered by this Workers pipeline.extra/holds planning docs — never deployed.