Two Ways to Deploy to Vercel
Vercel has a very easy built-in Git integration: connect the repo, and every push deploys automatically, end of story. That's great, but for this series we're deliberately using a more explicit approach — deploying through our own GitHub Actions.
Why? Because we want deployment to genuinely be the final stage of the pipeline we've been building: test first, then build, then deploy. With the native integration, Vercel deploys on its own regardless of what our tests in GitHub Actions actually say. This explicit approach is also what's typically used if you later move to a different platform — the concept is portable.
Preparing the App for Vercel
Vercel is a serverless platform — each request is ideally handled by a short-lived function, rather than an always-on server like the app.listen() pattern we're used to. Fortunately, our Express app is easy to adapt.
Step 1 — create api/index.js:
module.exports = require('../src/app');
This file just re-exports the exact same Express app we've already tested in previous articles. We're not rewriting any logic — just one line.
Step 2 — create vercel.json at the root:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/api/index" }
]
}
This line tells Vercel: route every request, no matter the path — /health, /api/tasks, anything — to the api/index.js function. Express itself then handles internal routing, exactly like when it runs locally.
Before moving on, we can test this Vercel behavior locally with npx vercel dev, opening localhost:3000/health, and confirming the response matches what we had before.
Setting Up a Vercel Project & Getting Credentials
npm install --global vercel
vercel login
vercel link
vercel link connects our local folder to a project in our Vercel account — if one doesn't exist yet, it creates a new one. After this, a .vercel/ folder appears, containing project.json with orgId and projectId. This folder should NOT be committed — which is why it's already in .gitignore from the start.
Grab a token:
Now we have three pieces of information: VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID. All of these are credentials that must be kept secret — remember the GitHub Secrets concept from the previous article? That's exactly what we use here.
Add these 3 repository secrets under Settings → Secrets and variables → Actions:
VERCEL_TOKENVERCEL_ORG_IDVERCEL_PROJECT_IDThe deploy Job in the Workflow
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment info
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build project artifacts for Vercel
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel (production)
id: deploy
run: |
url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment-url=$url" >> "$GITHUB_OUTPUT"
A few important points:
needs: build — deploy only runs after the build job (and, transitively, test) succeeds. This is the real implementation of the pipeline diagram from the pipeline anatomy article: test → build → deploy.if: github.ref == 'refs/heads/main' && github.event_name == 'push' — this job is SKIPPED when the run is triggered by a pull request. We only want to deploy to production once the code has actually landed on main.environment: production — this is the GitHub Environments feature, covered in the next section for the manual gate.vercel pull → vercel build → vercel deploy --prebuilt — the official Vercel CLI flow for CI/CD: pull the project configuration, build on the GitHub runner (not on Vercel's servers), then deploy the already-built result. This gives us full control and full transparency over what's actually being deployed.Once the run finishes, open the URL that appears in the output — that's our app, now live on Vercel. Try hitting the /health and /api/tasks endpoints directly from a browser or curl.
Manual Approval Gate — GitHub Environments
Remember in the first article we covered the difference between Continuous Delivery and Continuous Deployment? The only difference is whether there's manual approval before production or not. Now let's actually put that into practice.
The steps:
production (must match exactly what's in ci.yml).Now, as soon as the deploy job is about to run, the pipeline will PAUSE and wait for manual approval — a notification goes out to the reviewers we set. Only after it's approved does the deployment continue.
If you push to main again, the deploy job will show as "Waiting." Click "Review deployments," click "Approve and deploy," and the job continues. With this single toggle, our pipeline automatically flips from Continuous Deployment into Continuous Delivery. Once your team is fully confident in its test coverage, just turn off the required reviewer, and it goes back to full Continuous Deployment.
Summary
We just finished an end-to-end pipeline: push code → test across multiple Node versions → build & package into an artifact → automatically deploy to Vercel, complete with a manual approval gate via GitHub Environments. This pipeline has the exact same structure used by real production teams, just scaled down for learning.