Vercel deploy model
How Vercel turns code into a running URL — build time vs deploy time vs runtime, what `vercel.json` controls, and the four archetypes of "Vercel project linked to a Git repo".
The repo deploys five apps to five Vercel projects, all under one mechanism but in two different shapes:
- Actions-owned (
today-web,today-admin,today-design-system,today-dev-docs): GitHub Actions callsvercel build+vercel deploy --prebuilt, Vercel's own Git auto-deploy is disabled. - Vercel-Git-owned (
webview-bridge-docs): no Actions workflow; Vercel for GitHub builds + deploys directly on push.
To pick the right shape for a new app — or to debug why an old one behaves oddly — you need a clear mental model of what Vercel does and when. This page covers the underlying model; Workflow → Deployment covers our concrete per-app configuration.
Three lifecycle phases
Every deploy passes through three phases. Different config sources, different env vars, different things you can change.
+-------------------+ +------------------+ +----------------------+
| Build time | | Deploy time | | Runtime |
| (source -> output)| | (output -> CDN) | | (request -> response)|
+-------------------+ +------------------+ +----------------------+| Phase | Runs where | Reads | Produces |
|---|---|---|---|
| Build time | Vercel build container, OR your CI (vercel build) | vercel.json buildCommand / installCommand / framework / outputDirectory; Project env vars scoped to Build (incl. NEXT_PUBLIC_* inlined into client bundles) | .vercel/output/ (Build Output API format) |
| Deploy time | Vercel control plane (CDN / Edge network) | vercel.json rewrites / redirects / headers / cleanUrls / trailingSlash; functions block (per-Serverless/Edge function region, memory, maxDuration); crons; project domain bindings, alias, Production/Preview target | A Deployment URL (xxx-yyy.vercel.app) + alias |
| Runtime | Edge / Node Function / static asset on user request | Project env vars scoped to Runtime; process.env inside Functions; System env vars (VERCEL_ENV, VERCEL_URL, VERCEL_GIT_*) | HTTP response |
A few cases that bite repeatedly:
NEXT_PUBLIC_*is a Build-time concern, not Runtime. Changing the value in the Vercel project settings doesn't change anything until you redeploy.vercel.jsonrouting fields (rewrites,headers, etc.) are compiled into.vercel/output/config.jsonat build time and consumed by Vercel's router at deploy/runtime. Changing them requiresvercel build+vercel deploy. They are not runtime-mutable.ignoreCommandruns before build time — it's a "should we even build?" gate. Vercel runsgit diffplus your command on the just-pushed commit; if the command exits 0, Vercel skips the build entirely. This is the cheapest form of build-skipping when nothing in your scope changed.
System env vars (VERCEL_ENV, VERCEL_URL, etc.) are only auto-injected when
Vercel itself runs the build. If you vercel build from GitHub Actions, you
need vercel pull --environment=preview|production first — the CLI writes them
to .vercel/.env.{env}.local for vercel build to consume.
See Environment variables for the full Next.js inlining
story (process.env.NEXT_PUBLIC_* substitution, .env.{mode}.local priority,
the vercel pull flow).
Project, deployment, alias, target
These four primitives compose every Vercel concept.
| Term | Meaning |
|---|---|
| Project | The unit Vercel deploys. One Vercel project per app in our repo (five total). Each has its own settings, env vars, and (optionally) a Git link. |
| Deployment | An immutable build of one commit. Identified by a unique dpl_* ID and a hash URL <hash>.vercel.app. Every build creates one. |
| Alias | A pretty URL pointing at a deployment. today.ai is an alias on the latest production deployment of today-web. Aliases are mutable; deployments are not. Promote / rollback = "move the alias to a different deployment". |
| Target | The environment label on a deployment: production or preview. Determines which env-var scope was active during vercel pull and which alias pool applies. |
Each Git push creates one or more deployments. Switching production is a sub-second alias change; the previous deployment is still reachable at its hash URL.
The Vercel project ↔ Git repo spectrum
A Vercel project's relationship with its Git repo isn't binary "linked / not linked". It's a four-archetype spectrum, and the choice affects what's automated and what isn't.
| Archetype | State | Behavior |
|---|---|---|
| A. Unlinked | No Git repo at all | CLI-only deploys. No branch alias, no VERCEL_GIT_* vars, no PR comments, no Production Branch UI. |
| B. Linked + auto-deploy disabled | Linked, git.deploymentEnabled: false in vercel.json | Vercel sees the repo (PR comments, Production Branch, VERCEL_GIT_* populated when CLI runs in Actions), but does not build on push. CI is responsible. |
| C. Linked + auto-deploy enabled | Linked, no override | Push triggers build + deploy on Vercel. Default behavior; zero workflow needed. |
| D. Deploy Hook | Linked, but deploys via HTTP webhook | A unique URL that triggers a deploy without a new commit. Useful for "rebuild on CMS change". |
Our five projects sit at B or C:
| Project | Archetype | Why |
|---|---|---|
today-web | B | Actions builds + deploys via prebuilt; tight control over CI gating |
today-admin | B | Same |
today-design-system | B | Same |
today-dev-docs | B (intended) | Actions workflow exists; see Deployment for current state |
webview-bridge-docs | C | Pre-dates the Actions convention; pure Vercel Git |
The keep-linked-but-disable-auto-deploy hybrid (B) is the load-bearing pattern
because it preserves everything link gives you for free — branch alias,
Git metadata, PR commit status, deployment list with author / SHA — while
keeping CI as the single build authority.
The two off-switches and what they actually do
When you want to suppress Vercel's auto-deploy, you have two knobs. They are not equivalent.
push
|
v
+---------------------------------+
| git.deploymentEnabled in |
| vercel.json -- false? |
+---------------------------------+
| |
yes | | no
v v
no deploy +-----------------+
| previewDeploy- |
| mentsDisabled |
| (project-level) |
+-----------------+
|
+-----------+-----------+
| |
yes | | no
v v
preview: no deploy Vercel auto-deploys
production: still
deploys (production
branch only)| Setting | Where | Disables | Notes |
|---|---|---|---|
git.deploymentEnabled: false | vercel.json (per commit) | All auto-deploys (preview + production) | This is the documented "turn off automatic Git deployments" switch. Wins over project-level config. |
previewDeploymentsDisabled: true | Project settings (Vercel UI → Settings → Git) | Preview deploys only | Production-branch pushes still create deploys unless git.deploymentEnabled: false also set. |
A third field — gitProviderOptions.createDeployments: "enabled" — is visible
in the API but is not an auto-deploy switch. It controls whether Vercel
writes GitHub Deployment API records and commit-status entries for Vercel-side
builds. When git.deploymentEnabled: false suppresses the build entirely,
there's nothing for createDeployments to report; it's a no-op in that case.
In our setup the four Actions-owned projects have both git.deploymentEnabled: false (in vercel.json) and previewDeploymentsDisabled: true (project-level).
The vercel.json setting is the one actually doing the work; the project-level
toggle is redundant for the preview path. Don't take this as a recommendation
to set both — set git.deploymentEnabled: false and you're done.
What link gets you (even with auto-deploy off)
The reason all four Actions-owned projects keep the Git link instead of going unlinked (archetype A):
- Branch aliases: each preview deployment automatically gets
<project>-git-<branch>-<team>.vercel.app— stable per-branch URL that always points at the latest deploy for that branch. Unlinked projects mustvercel alias setmanually. VERCEL_GIT_*env vars:VERCEL_GIT_COMMIT_SHA,VERCEL_GIT_COMMIT_REF,VERCEL_GIT_REPO_SLUG, etc. Vercel CLI in GitHub Actions readsGITHUB_*env vars and translates them, but the project needs a Git link to know which repo they're for.- PR commit status: the Vercel for GitHub app posts a
Vercel – <project>status check on each commit. Still posted on prebuilt deploys. - Production Branch semantics:
vercel deploy --prodfrom CLI knows which branch is production based on the link. - Deployment list UX: each deployment in the Vercel dashboard shows commit message, author, branch — populated from the Git link.
Practically: link the project, set git.deploymentEnabled: false in
vercel.json, and treat Vercel as a managed CDN that consumes prebuilt
artifacts from Actions. You keep all the integration nice-to-haves without
duplicating builds.
When CI builds and Vercel builds duplicate
A subtle case the team has occasionally seen: ci.yml's Build job and a
deploy-*.yml's vercel build step are both valid Build-time invocations
of the same code. They produce different artifacts (CI's gets discarded;
deploy's becomes .vercel/output for prebuilt upload) and they're scoped
differently (CI uses pnpm --filter across affected apps; deploy targets a
single project) — but they compile the same source.
For a PR that touches apps/web, you currently get two next build
invocations of apps/web running in parallel:
ci.yml `Build` job 06:52:13Z -> 06:55:44Z (~3.5 min, all affected apps)
deploy-web.yml `Build` step 06:52:38Z -> 06:54:14Z (~1.5 min, web only)The duplication is intentional, not a bug — they serve different purposes:
- CI's
Buildjob is the required check for branch protection. It also covers targets that have no deploy workflow (packages, scripts, etc.). It's the build correctness gate. - Deploy's
vercel buildproduces the shippable artifact with Vercel's env injection and output format. It's the deploy mechanism.
If compute matters, the lever to pull is ordering inside the deploy workflow, not deleting either build:
current order: vercel pull -> vercel build -> wait for CI -> vercel deploy
better order: vercel pull -> wait for CI -> vercel build -> vercel deployWith the current order, if CI fails after vercel build succeeded, the
deploy artifact is built and then thrown away. Reordering short-circuits
that wasted compute when CI is red. This is a follow-up worth taking once
build minutes start to matter; the current order isn't broken, just
suboptimal.
See also
- Workflow → Deployment — per-app concrete config, environment mapping, required Vercel setup checklist, rollback flow
- Architecture → Environment variables — Next.js
inlining mechanics,
.env.*.localpriority, build-time vs runtime visibility - Workflow → CI affected scope — why CI
builds only changed packages, how the
pnpmFilteris computed - Vercel docs: Git Configuration
- Vercel docs: Deploying with GitHub Actions
Environment detection
getEnvironment() returns development / preview / production — a stable literal across SSR and hydration. Used to gate OnboardingRedirectGuard and debug tooling.
Auth interaction architecture
The headless auth server pattern — OAuth2 / OIDC server delegates login, consent, and account selection to a web app via HTTP redirects.