A main/staging/api Branch Strategy: Safe Deploys with Vercel Previews and GitHub Actions
A three-lane branch model for safe deploys: main as the source of truth, staging for Vercel previews, api for VPS deploys via GitHub Actions.
The problem: pushing to main goes live instantly
Once your frontend is connected to Vercel, every push to the production branch is promoted automatically and your main domain immediately serves the new version. This is Vercel's official default: pushing to or merging into the production branch triggers a production deployment, and custom domains switch to the new deployment the moment the build succeeds. Fast and convenient — until one commit leaks to visitors because it was never tested.
This article sums up the branch model I run in production for this very blog: three branches (main, staging, api) that each have exactly one job, a frontend on Vercel, and an API on a VPS via GitHub Actions. The model evolved after migrating the frontend to Vercel — not just theory lifted from docs.
The three-branch model: one source of truth, two deploy pointers
The core idea is simple: only main holds code. The other two branches are not places to develop — they are pointers to specific commits on main:
main— the source of truth. All work is committed here. Vercel treats it as the production branch.staging— a pointer for Vercel preview deployments. Pushing to any non-production branch automatically produces a preview; this branch simply becomes the permanent "testing station".api— a pointer that triggers the GitHub Actions workflow deploying the API to the VPS.
Because both are mere pointers, promotion is always a fast-forward — merge conflicts never happen, and git history stays linear:
# test the frontend on a preview before going live
git checkout staging && git merge --ff-only main && git push origin staging && git checkout main
# deploy the API to the VPS
git checkout api && git merge --ff-only main && git push origin api && git checkout main
This "promotion = merge between branches" pattern mirrors what the GitOps world does: OneUptime's promotion guide, for instance, describes the sequence git checkout staging && git merge develop && git push origin staging as the standard way to move changes between environments. The difference here is that the environments are not Kubernetes clusters but a Vercel preview and a VPS.
One crucial note on ordering: pushing to staging does not make anything live. The production domain only changes when main is pushed. So the safe flow runs opposite to most people's first intuition — merging into main can happen first, because what decides when visitors see the change is the push itself; staging just gets fast-forwarded whenever you need a test URL.
How Vercel treats your branches
Two rules make the whole model click:
- One production branch, everything else is preview. Per the Vercel Git documentation, every branch other than the production branch is treated as a preview branch. Pushing to any branch (or opening a PR) yields a deployment with an automatic URL.
- Two kinds of preview URLs. Vercel hands out a branch-specific URL that always points at the latest commit on that branch (great to share during review), plus a commit-specific URL that freezes one exact version (great for tracking down regressions).
A project's very first deployment is always a production one, no matter how you created it — that is also the documented default. After that, normal preview rules apply: only the production branch touches the main domain.
Practical setup
On the Vercel side you only need two things:
- Production branch pointed at
main(the default — just confirm it). - Environment variables set for both the Production and Preview environments — e.g.
NEXT_PUBLIC_API_URL,NEXT_PUBLIC_SITE_URL. Vercel separates variables per environment; the docs explicitly state preview variables cannot be used in a production deployment, so fill in both or previews fall back tolocalhost.
Then create the pointer branches once, up front:
git checkout main
git checkout -b staging && git push -u origin staging
git checkout main
git checkout -b api && git push -u origin api
git checkout main
Done. No extra Vercel configuration is needed for staging — pushing any non-production branch already produces a preview automatically.
The API path: GitHub Actions to a VPS
The backend is not hosted on Vercel, so the api branch acts as a classic CI/CD trigger. The workflow fires on pushes to api (plus manual dispatch for a specific ref: tag, branch, or SHA), then runs a single deploy job over SSH: pull the commit, build the Docker image, health-check the /api/health endpoint, verify the build version, and auto-roll back on failure. The minimal trigger looks like this:
on:
push:
branches: [api]
workflow_dispatch:
Version tags (vX.Y.Z) are reserved for deploying a specific older ref — day-to-day deploys only need the branch fast-forward.
Alternative: staged production deployments
Vercel offers a second, lesser-known testing path: the staged production deployment. Turn off Auto-assign Custom Production Domains under Settings → Environments → Production, and every push to main still builds a production deployment — but the domain does not switch until you hit Promote in the dashboard. The staging environment setup guide covers both options side by side.
When to pick which?
- A
stagingbranch + preview — fits when the build under test needs different environment variables, you want a stable URL for QA, or the API follows the same merge-between-branches pattern. - Staged production — fits when what you want to verify is the production build itself (production variables, real domains) before exposing it to visitors, with no rebuild on promote.
The two are not mutually exclusive; my model picks the branch approach for its symmetry with the api path on the VPS.
Where this model sits on the branching-strategy map
Compared with the classic patterns, the three-pointer model is a lean variant of environment branches. General references name four big families: GitHub Flow (a single main, deploy on every merge), GitFlow (develop + release + hotfix, for scheduled releases), GitLab Flow (one branch per environment, changes flowing one way), and trunk-based development. The full map is summarized by DeployHQ, as does the Azure Repos branching guidance.
The dividing line: in full GitLab Flow, code is genuinely developed and stabilized on each environment branch, so merge conflicts are a real risk. In the pointer model, environment branches never receive unique commits — git merge --ff-only will refuse if that ever happens — so that complexity disappears entirely. The trade-off: this model only fits when exactly one version is live at a time, with no separate long QA phase. For scheduled releases, mobile-app style, GitFlow remains the better fit.
Quick checklist
- Commit everything to
main;stagingandapiare fast-forward pointers only. - Want a test URL: fast-forward
stagingand push — grab the branch-specific URL from the dashboard. - Deploy the API: fast-forward
apiand push; the Actions workflow runs build, health checks, and automatic rollback. - Need to verify a production build before going live: disable domain auto-assignment and use Promote.
- Never commit directly to
staging/api— the--ff-onlypattern exists precisely to block that.
The simplest model that works is the one you can explain in a single sentence: main is the code, staging is "show me first", api is "run it now".