FirstDevJob Docs
Technical

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

Push to Master: CI, Convex deploy and the Vercel build run in parallel
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.

JobSteps
qualitybun run lint, bun x tsc --noEmit, bun run test:ci, upload coverage to Codecov (non-blocking)
buildbun run build with NEXT_PUBLIC_CONVEX_URL (falling back to a dummy URL) and NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
securitybun run audit:deps — bun audit at high severity with three advisories explicitly ignored
dependency-reviewPull 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.

  1. test-before-deploy — lint, bun x tsc --noEmit, bun run test:ci, and a production build. This duplicates ci.yml on purpose: the deploy must not depend on another workflow's result.
  2. deploy-convex — needs test-before-deploy, then runs bunx convex deploy with CONVEX_DEPLOY_KEY. Production is the default target for convex deploy, and the deploy key identifies the deployment, so there is no --prod flag here. This pushes functions and applies the schema.
  3. deploy-vercel — skipped, as described above.

Required GitHub configuration

NameKindPurpose
CONVEX_DEPLOY_KEYSecretAuthenticates CI against the production Convex deployment
NEXT_PUBLIC_CONVEX_URLSecretUsed by the CI build steps
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYSecretUsed by the CI build steps
VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_IDSecretsOnly needed if the Vercel job is ever enabled
VERCEL_CONFIGUREDRepository variableSet 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.

NameNotes
CLERK_JWT_ISSUER_DOMAINRead by convex/auth.config.ts
RESEND_API_KEYCredentials for the Resend component
RESEND_TEST_MODEMust be false in production; defaults to test mode when unset
EMAIL_FROMSender address for both notification emails
STAFF_NOTIFICATION_EMAILSComma-separated moderation recipients
NEXT_PUBLIC_SITE_URLBase URL used to build links inside emails
EMAIL_REPLY_TOOptional, 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)

NameNotes
NEXT_PUBLIC_CONVEX_URLWhich Convex deployment the browser talks to
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYClerk frontend key
CLERK_SECRET_KEYServer-side Clerk key, never exposed to the browser
NEXT_PUBLIC_FEEDBACK_BOARD_URLOptional; 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.com

Copying production data into dev

bun run sync:prod-to-dev

This 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

  1. GitHub Actions: CI and Production Deployment both green, deploy-convex succeeded.
  2. Vercel: the deployment for the same commit is Ready.
  3. Convex dashboard: the production deployment shows the new functions; check logs for scheduled email actions.
  4. 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.

On this page