Skip to content

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.

The pipeline is split to keep root lean — root holds only globals + shared jobs:

FileHolds
.gitlab-ci.yml (root)globals (image, stages), include:, and app-agnostic jobs: install, verify (typecheck/lint/unit), integration, migrate.
apps/web/.gitlab-ci.ymldeploy:web + its .changes_web anchor.
apps/cron-daily/.gitlab-ci.ymldeploy: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:* can needs: the root-defined install/verify/migrate jobs.
TriggerWrangler envPath filterDeployed Worker names
push to maindevelopmentyes (changes:)xprivate-web-development, xprivate-cron-daily-development
release tag vX.Y.Zproductionno (full deploy)xprivate-web-production, xprivate-cron-daily-production
any other branch / MRno 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 deployedxprivate-web-production / -cron-daily-production do not exist in Cloudflare yet. The prod path is wired and gated (protected v* tag), but stays dormant until the first git tag vX.Y.Z. Until then everything ships to the development env on each main push. Do not cut a prod tag until the maintainer declares prod-ready.

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 pathRedeploys
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.jsonweb + cron-daily

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, db
service → auth, db
auth → db
db → (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.

  1. Add apps/<new>/wrangler.jsonc with env.production + env.development.
  2. Add apps/<new>/.gitlab-ci.yml with a deploy:<new> job + its own .changes_<new> anchor (mirror apps/web/.gitlab-ci.yml); do NOT redeclare globals there.
  3. Register it in root .gitlab-ci.yml under include: (- local: apps/<new>/.gitlab-ci.yml).
  4. Set its rules: changes: to its own transitive closure — not a copy-paste of every package path.

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.crons schedule. The development env 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.

AppTypeEnv chosen atHowDeploy command
webTanStack Start (SSR, needs a Vite build)build timeCLOUDFLARE_ENV=<env> read by @cloudflare/vite-pluginwrangler deploy (no --env)
cron-dailyplain script worker (no build)deploy timewrangler --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.

.gitlab-ci.yml stage order: install → verify → integration → migrate → deploy.

  • migrate job: applies Drizzle migrations (idempotent) to the persistent Neon DB before deploy. Trigger-selects the DB — main push → NEON_DATABASE_URL_DEV, release tag → NEON_DATABASE_URL_PROD; skipped until the matching var exists. deploy:web and deploy:cron both needs: it as optional: true so 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. Each needs: the install job (for the node_modules artifact) plus the verify jobs (typecheck, lint, unit) green before deploying.
  • Each job’s rules: match the trigger (→ set DEPLOY_ENV): a release tag → production (no changes: — always deploys), or a main push → development AND the changes: paths for that app’s closure. No match → the job does not run.
  • deploy:web runs CLOUDFLARE_ENV=$DEPLOY_ENV bun run --cwd apps/web build (Vite → dist/), then wrangler deploy from apps/webno --env (env baked at build; see Env selection above). Verify: wrangler deployments list --name "xprivate-web-$DEPLOY_ENV".
  • deploy:cron deploys src directly with wrangler 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 the integration job gating on $NEON_API_KEY).
VariableValue
CLOUDFLARE_API_TOKENCloudflare API token (permissions below). Mark Masked + Protected.
CLOUDFLARE_ACCOUNT_IDtarget Cloudflare account ID.
NEON_DATABASE_URL_DEVDirect (non-Hyperdrive) Neon connection string for the dev DB; drizzle-kit migrate target. Mark Masked + Protected.
NEON_DATABASE_URL_PRODDirect 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_PROD resolve 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’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 groupScopeLevelWhy
Workers ScriptsAccountEditdeploy worker code (cron triggers, static assets)
HyperdriveAccountEditdeploy worker bound to Hyperdrive + create per-env configs ⚠️ not in template
Workers R2 StorageAccountEditR2 binding + create the xprivate-uploads-dev bucket
Account SettingsAccountReadresolve target account
User DetailsUserReadtoken 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.jsonc and apps/cron-daily/wrangler.jsonc env.production538922a1bdab4ac1b12c7f39019ecbd8, env.development8f247a9f732348cda8d7eb2a88f6894a). 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.jsonc still 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_ID not yet set → deploy jobs are skipped until added.
  • NEON_DATABASE_URL_DEV / NEON_DATABASE_URL_PROD not yet set → migrate job is skipped until added; once set, persistent-DB migration is fully automated.
  • sites/*.pages.dev are Cloudflare Pages static sites (architecture/flowchart docs) — a different deploy target, not covered by this Workers pipeline.
  • extra/ holds planning docs — never deployed.