Deployment
How code and configuration reach production
Production is two independently deployed halves: the Convex backend, pushed by GitHub Actions, and the Next.js frontend, built by Vercel's Git integration. A push to Master starts both at once. Nothing here deploys from a laptop, and no environment variable value belongs in the repository — only names appear below.
What happens on a push to Master
Diagram source
flowchart TD push["git push to Master"] --> ci["ci.yml"] push --> deploy["deploy.yml"] push --> vercel["Vercel Git integration"] ci --> quality["quality: lint, tsc --noEmit, test:ci"] ci --> build["build: bun run build"] ci --> security["security: bun run audit:deps"] deploy --> pre["test-before-deploy: lint, types, tests, build"] pre --> convex["deploy-convex: bunx convex deploy"] convex --> vjob["deploy-vercel (skipped)"] convex --> prodbe["Convex production deployment"] vercel --> vbuild["next build with NEXT_PUBLIC_* inlined"] vbuild --> prodfe["Production frontend at first-dev-job.com"] prodbe --> live["Live site"] prodfe --> live
The deploy-vercel job in deploy.yml is gated on the repository variable VERCEL_CONFIGURED == 'true', which is not set — so the job is always skipped and the frontend is deployed by Vercel's own Git integration instead. That means the Convex deploy and the Vercel build race: a backend change and the frontend that depends on it are not guaranteed to go live in order. Ship backwards-compatible schema and function changes first, then the frontend that uses them.
ci.yml
Runs on every pull request, every push to Master, and on manual dispatch. Concurrency is grouped per PR or ref with cancel-in-progress, so a new commit cancels the previous run. Bun 1.4.2, with ~/.bun/install/cache keyed on bun.lock, and bun install --frozen-lockfile everywhere.
| Job | Steps |
|---|---|
quality | bun run lint, bun x tsc --noEmit, bun run test:ci, upload coverage to Codecov (non-blocking) |
build | bun run build with NEXT_PUBLIC_CONVEX_URL (falling back to a dummy URL) and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
security | bun run audit:deps — bun audit at high severity with three advisories explicitly ignored |
dependency-review | Pull requests only; fails on high-severity dependency changes |
The ignored advisories are transitive pins with no patched release in range (image-size via fumadocs-core, js-cookie via @clerk/shared). Re-check them with bun audit when bumping Fumadocs or Clerk.
deploy.yml
Runs on pushes to Master and on manual dispatch.
test-before-deploy— lint,bun x tsc --noEmit,bun run test:ci, and a production build. This duplicatesci.ymlon purpose: the deploy must not depend on another workflow's result.deploy-convex— needstest-before-deploy, then runsbunx convex deploywithCONVEX_DEPLOY_KEY. Production is the default target forconvex deploy, and the deploy key identifies the deployment, so there is no--prodflag here. This pushes functions and applies the schema.deploy-vercel— skipped, as described above.
Required GitHub configuration
| Name | Kind | Purpose |
|---|---|---|
CONVEX_DEPLOY_KEY | Secret | Authenticates CI against the production Convex deployment |
NEXT_PUBLIC_CONVEX_URL | Secret | Used by the CI build steps |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Secret | Used by the CI build steps |
VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID | Secrets | Only needed if the Vercel job is ever enabled |
VERCEL_CONFIGURED | Repository variable | Set to true to enable the deploy-vercel job |
Environment variables
Two separate systems, two separate places to set them. A variable set in Vercel is invisible to Convex functions, and vice versa.
Convex (production deployment)
Set in the Convex dashboard, or with bunx convex env set --prod NAME value.
| Name | Notes |
|---|---|
CLERK_JWT_ISSUER_DOMAIN | Read by convex/auth.config.ts |
RESEND_API_KEY | Credentials for the Resend component |
RESEND_TEST_MODE | Must be false in production; defaults to test mode when unset |
EMAIL_FROM | Sender address for both notification emails |
STAFF_NOTIFICATION_EMAILS | Comma-separated moderation recipients |
NEXT_PUBLIC_SITE_URL | Base URL used to build links inside emails |
EMAIL_REPLY_TO | Optional, comma-separated reply-to addresses |
CLERK_JWT_ISSUER_DOMAIN is the issuer domain only — for example https://clerk.example.com. It is not the JWKS URL and must not end in /.well-known/jwks.json. A wrong value here makes every authenticated Convex call fail while the frontend still looks signed in.
RESEND_TEST_MODE defaults to true when it is unset or unparseable (convex/resend.ts). A production deployment that never sets it will accept sends and deliver nothing. Set it to false deliberately.
NEXT_PUBLIC_SITE_URL carries the NEXT_PUBLIC_ prefix but is read server-side by Convex actions, so it must be set on the Convex deployment, not only in Vercel.
Vercel (Next.js build and runtime)
| Name | Notes |
|---|---|
NEXT_PUBLIC_CONVEX_URL | Which Convex deployment the browser talks to |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk frontend key |
CLERK_SECRET_KEY | Server-side Clerk key, never exposed to the browser |
NEXT_PUBLIC_FEEDBACK_BOARD_URL | Optional; when unset the feedback links and page degrade gracefully |
NEXT_PUBLIC_* values are inlined into the JavaScript bundle at build time. Changing one in the Vercel dashboard does nothing until a new build runs — redeploying the existing build reuses the old value. Trigger a fresh build after any change.
Clerk production keys are bound to the domains configured in the Clerk dashboard, so a preview URL will not authenticate against production keys.
.env.example lists every name, including CONVEX_DEPLOYMENT which is written by bunx convex dev for local work.
Deployments and data
Convex dev and prod are separate deployments with separate data and separate environment variables. Commands operate on dev unless told otherwise, so promoting configuration means re-running it with --prod.
bunx convex env list --prod
bunx convex env set --prod EMAIL_FROM hello@example.comCopying production data into dev
bun run sync:prod-to-devThis is destructive and it moves personal data. It exports the production snapshot and runs convex import --replace against your dev deployment, wiping whatever dev currently holds, and the snapshot contains real user emails and live unsubscribe tokens. The temporary prod-snapshot.zip is deleted afterwards. Do not run it unless you need production data to reproduce something, and never point it the other way.
Domain and edge
The site is served at first-dev-job.com. The apex host issues a 307 redirect to the www host at Vercel's edge; that redirect is Vercel project configuration, not code, so it will not appear in next.config.ts.
next.config.ts does own the security headers applied to every response: Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and Content-Security-Policy: frame-ancestors 'none'. Only frame-ancestors is set for CSP — a full policy needs an allowlist for Clerk, Convex and Vercel and has not been rolled out.
Verifying a deploy
- GitHub Actions:
CIandProduction Deploymentboth green,deploy-convexsucceeded. - Vercel: the deployment for the same commit is Ready.
- Convex dashboard: the production deployment shows the new functions; check logs for scheduled email actions.
- The site: sign in, load
/dashboard, and confirm a Convex query returns data — that exercises Clerk, the JWT issuer configuration and Convex in one step.
Rolling back
Convex keeps previous pushes: redeploy the last good commit through deploy.yml (manual dispatch) rather than editing functions in the dashboard. Vercel can promote a previous deployment instantly from its dashboard. Because the two halves deploy independently, roll back the one that broke, and remember that a schema change is not undone by rolling back the frontend.