CI stands for Continuous Integration: every time someone pushes code, a machine automatically installs it, tests it, and builds it — the same way, every time, with no human remembering to run a command. CD stands for Continuous Deployment (or Delivery): if that build succeeds, the result is automatically shipped somewhere real.
Before this existed, "does it work?" meant one developer running tests on their own laptop and hoping their machine matched everyone else's. CI/CD replaces that hope with a machine that runs the exact same checks, in the exact same environment, on every single change — and refuses to let broken code merge or deploy silently.
💡 Worth knowing: the value of CI isn't catching bugs a human couldn't find — it's catching them immediately, before they're buried under ten more commits, and consistently, without depending on someone remembering to run
npm testbefore pushing.
The acronym "CI/CD" hides a distinction worth knowing, because "CD" actually gets used two different ways, and people often use it loosely:
Vercel's default workflow is Continuous Deployment: merge to main, and it's live, full stop. Plenty of teams deliberately choose Continuous Delivery instead — for something like a banking system, an extra human checkpoint before production might be worth the added friction. Neither approach is "more correct." They trade speed against a manual gate, and the right choice depends on how expensive a mistake in production would actually be.
💡 Worth knowing: if someone says "we do CD," it's worth asking which one they mean. The difference between "ready to ship" and "already shipped" changes what a green pipeline actually promises you.
A YAML file is the easy part to point at, but it isn't actually what CI/CD is. Continuous Integration started as a practice, not a tool: developers merge their work into one shared mainline multiple times a day, in small pieces, rather than working in isolation for days or weeks and reconciling everything at the end. The pipeline is just the automated referee that checks each of those small merges — it exists to serve that habit, not the other way around.
Continuous Deployment takes it one step further: once a change passes those checks on the mainline, it ships — automatically, not "eventually, after a review meeting." The whole point is to make shipping small changes routine and low-stakes instead of rare and terrifying.
💡 Key takeaway: the pipeline automates the checking. The actual discipline of CI/CD is a human one — commit small, integrate constantly, and never let your copy of the code drift far from everyone else's.
That discipline needs a branching model to support it, and the standard one is trunk-based development: everyone works off a single shared branch (usually main), branches live for at most a day or two, and they merge back constantly instead of accumulating.
This is the opposite of workflows built around long-lived feature/* or release/* branches, where work stays isolated for days or weeks before one large merge at the end.
⚠️ Anti-pattern: long-lived branches. The longer a branch lives, the further it drifts from
main— and from everyone else's work. Merge conflicts get bigger the longer you wait, not smaller. Worse, you don't discover you conflict with a teammate's change until both branches are "done," which is the most expensive possible moment to find out. This is exactly the problem Continuous Integration was named to solve: integrating constantly, not once at the end.
A fair question: what about a feature that genuinely takes two weeks to build? Trunk-based development doesn't mean shipping half-finished work to real users — it means decoupling merging from releasing, and the standard tool for that is the feature flag.
A feature flag is a simple condition, usually backed by a config value or environment variable, that decides whether a piece of code runs:
if (isFeatureEnabled("new-project-layout")) {
return <NewProjectGrid />;
}
return <ProjectGrid />;
The new component can merge into main today — half-finished, disabled by default — and keep merging in small increments over the following two weeks, all on trunk, all covered by CI. Nobody sees it until the flag flips on. When it's ready, flipping the flag is the release; no risky big-bang merge required.
💡 This is what makes trunk-based development realistic, not just idealistic. Small, frequent, low-risk merges to a shared branch — even for work that isn't finished — because the flag controls who sees it, not the branch it lives on.
None of this works without trust in your test suite. Merging to trunk constantly, and deploying the moment a build passes, only makes sense if "the build passed" actually means something. Automating a deployment doesn't make broken code safer — it just makes it faster to ship.
This is why last week's testing skills aren't a separate topic from this week's — they're the reason any of this is safe to do. A thorough suite is what turns "we merge to main several times a day" from a reckless habit into a disciplined one.
Look ahead at the workflow in the next section: install, then test, then build. That ordering isn't arbitrary — a well-designed pipeline is organised around failing as cheaply and as quickly as possible.
A real-world pipeline usually has more stages than that minimal example, and the cheapest, fastest ones go first:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Build project
run: npm run build
If your code has a typo, you want to find out from a ten-second lint step — not by waiting three minutes for the full test suite, and you certainly don't want to discover it only after a slow build has already started. Ordering stages from cheapest-to-fail to most-expensive-to-fail means a broken change gets rejected in seconds instead of minutes, and nobody wastes a coffee break watching a build they now know will fail anyway.
💡 Key takeaway: a good pipeline isn't just "does everything eventually get checked" — it's "does the cheapest check run first," so feedback arrives in seconds whenever possible, not minutes.
Even with all of this in place, something will eventually reach production and misbehave in a way no check caught. The trunk-based, CI/CD way to handle that isn't to SSH into a server and hand-edit files until it's fixed — it's to treat a rollback as just another deployment: redeploy the last known-good commit, exactly the same way you deploy anything else, through the exact same pipeline.
This is a big part of why Vercel keeps your previous deployment alive until a new one fully succeeds, mentioned earlier — the "last known-good version" isn't a backup you have to go dig up, it's simply still running, one click (or one revert commit) away from being the live one again.
💡 Worth knowing: a team that's genuinely comfortable with their pipeline doesn't panic when something breaks in production — they revert, calmly, using the same automation that got the bad change there in the first place, and investigate the actual bug without the added pressure of an ongoing incident.
This material uses GitHub Actions because it's built into GitHub and free for public repositories, but it's one implementation of an idea, not the only one. GitLab CI, CircleCI, Jenkins, and Buildkite all solve the same problem — trigger on a change, run steps on a fresh machine, report pass or fail — with different YAML shapes and different hosting models.
💡 If you ever join a team using one of these instead, the concepts here transfer directly. You're not learning "GitHub Actions" so much as learning what a CI pipeline is; the specific tool is a detail you can pick up from its docs in an afternoon.
A GitHub Actions workflow is a YAML file that lives in .github/workflows/. It describes three things: what should trigger it, what machine it runs on, and what steps to execute.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build project
run: npm run build
A few things worth naming explicitly:
on lists the triggers. Here, the workflow runs on every push to main, and on every pull request targeting main — which is also what makes Vercel's preview deployments possible: a PR trigger gives you a chance to check a build before it ever touches production.jobs contains one or more jobs, each running on a fresh virtual machine (runs-on).steps run in order, top to bottom, on that same machine. Each uses: step runs a pre-built action; each run: step runs a shell command directly.💡 Order is not cosmetic. The workflow above only works because checkout happens before install, install happens before test, and test happens before build. Move any of these and the whole pipeline breaks — there's no code to test before it's checked out, and no
node_modulesto test with before install runs.
💡 What's
cache: npm? That one line tellssetup-nodeto reuse the packages it downloaded on previous runs instead of fetching every dependency from scratch each time. It keys the cache on yourpackage-lock.json, so it can never serve you stale dependencies — and it's one of the cheapest speed-ups you can add to any pipeline. You'll see it on almost every real-world workflow.
Once a workflow is pushed, GitHub actually runs it — and it's worth knowing exactly where to go look. Open your repository on GitHub and click the Actions tab: every run of every workflow lives there, most recent first, each one showing a green checkmark, a red cross, or a spinning yellow circle while it's still in progress.
Click into any run and you'll see the exact same steps from your YAML file, in order, each expandable to show its full terminal output — literally the same output you'd see running those commands on your own machine, just captured from GitHub's machine instead of yours.
💡 When a run fails, start here, not with guessing. Click the red step, read the last twenty or so lines of its output, and you'll almost always find the actual error message — a failing test's assertion, a missing dependency, a typo GitHub is more than happy to point at exactly.
Push three commits to the same pull request in quick succession, and by default GitHub happily starts three separate workflow runs — even though only the last one's result actually matters to anyone. That's wasted time, and on a metered plan, wasted money too.
concurrency tells GitHub to cancel a still-running workflow the moment a newer one starts for the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
With this in place, only the most recent push's run is ever left running — anything it supersedes gets cancelled automatically, freeing up the runner instead of finishing a check nobody needs the answer to anymore.
💡 Small config, real impact. This one addition is common enough that it's worth recognising in any workflow file you read, even before you've fully worked out every other line in it.
A CI workflow that runs but doesn't actually block anything is only a suggestion. GitHub's branch protection rules (found under Settings → Branches on your repository) turn "please pass CI" into "you cannot merge until you do."
The setting that matters most here is require status checks to pass before merging. Once it's enabled, GitHub greys out the merge button on any pull request until your workflow finishes green — no exceptions, no "I'll just merge it and fix the failing test after."
💡 This is the missing link between "we have a pipeline" and "our trunk is always deployable." A pipeline that merely reports failures is a smoke detector with the battery removed. Branch protection is what makes it actually stop you — combined with trunk-based development, it's what guarantees
mainnever breaks by accident rather than by good intentions alone.
Sometimes the pipeline itself needs a secret — a token to deploy somewhere, or an API key to run a real integration check against a third-party service. You never hardcode that directly into the workflow file, because .github/workflows/*.yml is committed, visible in your Git history forever, and public if your repository is public.
GitHub gives you a dedicated place for this instead: Settings → Secrets and variables → Actions. Anything added there is encrypted at rest and only decrypted inside a running workflow, referenced through the secrets context:
- name: Run integration check
run: npm run check:integration
env:
API_TOKEN: ${{ secrets.INTEGRATION_API_TOKEN }}
The value never appears anywhere in your YAML file, and GitHub actively masks it in the workflow's run logs if it's ever accidentally printed — showing *** in place of the real value, rather than leaking it into a log anyone with repo access can read.
⚠️ A workflow secret is exactly as sensitive as any other secret. The rule from later in this material applies here too: never deliberately print it, and never let it flow somewhere — a build artifact, a deployed response — that a client could end up seeing.
npm ci instead of npm installNotice the workflow above uses npm ci, not npm install. They look similar but behave very differently in an automated environment.
npm install reads package.json, resolves the best-matching versions, and can update package-lock.json if something doesn't quite match. That's exactly what you want on your own machine while you're actively adding packages.
npm ci does the opposite: it deletes node_modules entirely, then installs precisely what's written in package-lock.json — no resolving, no updating. If the lockfile and package.json disagree even slightly, npm ci fails loudly instead of silently installing something slightly different than what you tested locally.
⚠️ This is exactly the guarantee CI needs. A pipeline that quietly installs different versions than your machine did defeats the entire point of automated testing — you'd be testing a different set of dependencies than the ones that actually ship.
Once a build passes, deployment can happen. Platforms like Vercel take this further than a single "live site": every pull request gets its own preview deployment — a real, working URL for that exact branch, before it ever merges. That's how a reviewer can click a link and see your change running, instead of trusting your description of it.
Remember last week's code review habits? This is where they extend naturally: a good pull request description doesn't just say what changed, it links the preview deployment, so the reviewer can click through and check the actual behaviour — not just read a diff and imagine it.
Production deployments follow the same idea. If a new deployment fails partway through — a build error, a failed check — the currently live version stays exactly as it was. Nothing goes offline because a deployment failed; the broken build simply never replaces the working one.
💡 Key takeaway: a failed deployment is a non-event for your users. The scary part isn't the pipeline failing — it's a pipeline that isn't there at all, silently letting something broken reach production.
There's one more property worth understanding about how a platform like Vercel actually ships a new version: it's atomic. The new version is built and fully assembled somewhere else entirely, completely separately from what's currently live, and only swapped into place once it's entirely ready — all at once, never file by file.
Compare that to naively copying new files over old ones on a traditional server: for the few seconds that copy takes, some visitors could receive a mix of old and new files — an old HTML page requesting a new, incompatible JavaScript bundle, for instance, which is exactly the kind of intermittent, hard-to-reproduce bug that makes deployments feel scary. Atomic deployment removes that window entirely: a visitor sees either the fully old version or the fully new one, never something in between.
💡 This is part of why "just redeploy the last good commit" from the rollback section works so cleanly. A rollback is just another atomic swap, in the other direction — not a partial, in-place repair with its own chance of going wrong halfway through.