CI/CD that small teams actually maintain
A practical CI/CD setup for teams of two to five. Fast checks, deploy on green, and the few guardrails that keep it from rotting six months later.

Most CI/CD advice is written for companies with a platform team. You are not that company. You are three people shipping a Next.js app, and the pipeline you copied from a conference talk now takes eleven minutes, flakes twice a week, and nobody remembers how the deploy step actually works.
This is the version that survives. It is boring on purpose. The goal is a pipeline a small team can read in one sitting, fix without a meeting, and trust enough to deploy on a Friday afternoon (carefully).
Start with what you actually need
A small team needs four things from CI, and nothing else:
- The code compiles.
- The tests pass.
- The thing you are about to deploy is the thing you tested.
- Deploys are one click, or zero clicks.
Notice what is missing: matrix builds across five Node versions, a custom Docker registry, blue-green orchestration, a Slack bot that posts a haiku on every merge. Those are real tools, but each one is a thing that breaks and that someone has to own. When you are small, every piece of infrastructure is a tax paid by the person on call. Add it only when the pain of not having it is louder than the pain of maintaining it.
The single rule that prevents most rot
One workflow file. Maybe two. If your .github/workflows directory has more files than your team has people, you have a problem that will compound. Pipelines rot because they grow features nobody removes. Keep the surface small enough that a single person can hold it in their head.
A real pipeline you can paste in
Here is a GitHub Actions workflow for a typical Next.js and TypeScript app with a Postgres-backed test suite. It runs on pull requests and on the main branch. It is the whole thing, not a fragment.
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Migrate test database
run: npm run db:migrate
- name: Test
run: npm test
- name: Build
run: npm run buildA few choices in there are deliberate.
The concurrency block cancels older runs when you push again to the same branch. Without it, a busy PR queues up five stale runs that all burn minutes and confuse everyone about which one matters. This single block is the cheapest reliability win in the file.
The Postgres service runs a real database instead of a mock. Your migrations and SQL get tested against the engine you ship on. A mocked database tells you your mock works, which is not the question you are asking.
npm ci instead of npm install. It installs exactly what is in your lockfile and fails if the lockfile is out of date. That is the behavior you want in CI: reproducible, not clever.
The steps are ordered cheapest-first. Typecheck and lint fail in seconds. There is no reason to spin up a database and run a full build only to discover someone left a console.log that trips the linter. Fast failures keep the feedback loop tight.
Make the pipeline fast, then keep it that way
Speed is not vanity here. A slow pipeline trains people to stop watching it, and a pipeline nobody watches is a pipeline that is quietly broken.
Three habits cover most of it:
- Cache dependencies. The
cache: npmline insetup-nodedoes this for free. For a heavier toolchain, cache the build output too. - Run independent checks in parallel only when it pays. For a small app, one sequential job is often faster than three jobs that each pay the setup cost of checkout plus install. Measure before you split.
- Kill flaky tests on sight. One flaky test does more damage than ten missing ones, because it teaches the team to re-run red builds out of habit. When a re-run turns a failure green, that is not luck, that is a bug you have not found yet. Quarantine it or fix it the same week.
A budget you can enforce
Pick a number. For a project this size, under five minutes on a pull request is a reasonable line. When you cross it, that is the signal to investigate, not a reason to shrug. The budget is the thing that keeps the pipeline honest as the codebase grows.
Deploy: boring is the feature
For most small teams on Vercel, Netlify, or a similar platform, the best deploy pipeline is the one the platform already gives you. Push to main, it builds, it goes live. Every PR gets a preview URL. You did not write that pipeline, so you do not maintain it, and that is the whole point.
If you self-host, keep deploys triggered by the same green build that ran your tests. The artifact you deploy must be the artifact you verified. A common failure mode is testing one commit and deploying a fresh build of main that happens to include an unmerged change. Pin the deploy to a specific commit SHA so there is no gap between what passed and what ships.
One guardrail worth the effort: migrations
Database migrations are where small-team CI/CD goes from boring to scary. The fix is to run migrations as an explicit, ordered step, and to write them so they survive the brief window where old and new code run at once.
The pattern that saves you: never drop or rename a column in the same deploy that stops using it. Split it across two deploys.
-- Deploy 1: add the new column, keep writing to both.
ALTER TABLE users ADD COLUMN email_normalized text;
-- Backfill in a separate, idempotent step.
UPDATE users
SET email_normalized = lower(email)
WHERE email_normalized IS NULL;
-- Deploy 2 (days later, after old code is fully gone):
-- ALTER TABLE users DROP COLUMN email;This is the difference between a deploy and a 2 a.m. incident. The old code reads the old column, the new code reads the new one, and at no point does a running process hit a column that does not exist. It is slower than doing it all at once, which is exactly why it works.
What to skip until you are bigger
Resist these until something concretely hurts:
- A monorepo build orchestrator. If
npm run buildtakes thirty seconds, you do not need affected-package detection yet. - Per-environment promotion pipelines. Two environments (preview and production) cover most small products. Staging often becomes a third thing to keep in sync and a graveyard of stale data.
- Custom GitHub Actions runners. The hosted ones are fine until your bill or your queue times say otherwise.
- Coverage gates set high. A 90 percent coverage requirement on a young codebase mostly produces tests written to satisfy the number. Track coverage, do not gate on it yet.
Every one of these is correct at some scale. None of them is correct on day one, and adding them early is how a three-person team ends up with a pipeline that needs a fourth person to babysit.
The takeaway
Good CI/CD for a small team is a short, fast, single workflow that proves the code compiles and the tests pass, deploys the exact commit it verified, and treats migrations with respect. Add complexity only when a specific pain demands it, and delete anything the team has stopped trusting. The pipeline you can read in five minutes is the one that will still be working in a year.
If you want a second pair of eyes on a pipeline that has started to rot, that is the kind of cloud and DevOps work we do.
