CI/CD for Small Teams: GitHub Actions Workflows We Actually Use
We are a team of four engineers. We do not have a dedicated DevOps person. We do not run Kubernetes. We do not have a “platform team.” What we do have is a set of GitHub Actions workflows that have kept our projects stable across 12+ shipped products without consuming all our time on pipeline maintenance.
This post is not about what a CI/CD pipeline should look like in theory. It is about the exact workflows we run, why we chose them, and what we intentionally left out.
The Philosophy: Automate the Boring, Skip the Clever
Small teams cannot afford to debug flaky CI pipelines. Every minute spent fixing a workflow is a minute not spent building features. Our CI philosophy is:
- Fast feedback — lint and type checks in under 2 minutes
- Reliable — no flaky tests, no intermittent failures
- Minimal maintenance — use standard tooling, avoid custom scripts
- Preview everything — every PR gets a live preview URL
If a workflow fails, it should be because there is a real problem in the code, not because a Docker layer cache expired or a third-party action updated.
Workflow 1: Lint and Type Check
This runs on every push and every PR. It is the fastest feedback loop and catches the most common issues.
# .github/workflows/check.yml
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
name: Lint & Type Check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Lint
run: npm run lint
- name: Type Check
run: npm run check
- name: Format Check
run: npx prettier --check .
A few deliberate choices here:
timeout-minutes: 5— If lint and type check take more than 5 minutes, something is wrong. The timeout catches hung processes.npm ciinstead ofnpm install—ciuses the lockfile exactly, which is faster and more reliable.cache: "npm"— Node setup action cachesnode_modulesautomatically. This cuts install time from 30s to 5s on cache hits.- Prettier as a separate step — Format issues are not lint errors. Separating them makes failure messages clearer.
We run lint and type check as a single job rather than parallel jobs. The overhead of spinning up a second runner (30-60s) is more than the time saved by running them in parallel (they each take ~20s on our codebases).

Workflow 2: Tests
Tests run on PRs and pushes to main. We keep them separate from lint because test failures need more context in the logs.
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
timeout-minutes: 10
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
SUPABASE_URL: http://localhost:54321
SUPABASE_ANON_KEY: ${{ secrets.TEST_SUPABASE_ANON_KEY }}
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Run Migrations
run: npm run db:migrate
- name: Run Tests
run: npm test -- --reporter=verbose
The PostgreSQL service container gives us a real database for integration tests without needing Docker Compose or a remote test database. Tests that need database access run against this local Postgres instance.
For projects that use Supabase heavily, we sometimes run the Supabase CLI locally instead:
- name: Start Supabase
run: |
npx supabase start
npx supabase db reset
This gives us RLS, auth, and edge functions in CI — closer to production than a bare Postgres container. We covered RLS patterns in detail in Row-Level Security in Supabase.
Workflow 3: Preview Deployments
Every PR gets a preview URL. This is the single most valuable CI feature for a small team. It lets us review changes in a real browser, share previews with clients, and catch visual regressions without pulling the branch locally.
For Vercel-hosted projects:
# .github/workflows/preview.yml
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy:
name: Deploy Preview
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
id: deploy
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
github-comment: true
- name: Comment Preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: ${process.env.PREVIEW_URL}`
})
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview-url }}
For Cloudflare Pages projects, the setup is even simpler — Cloudflare Pages has built-in preview deployments for every branch. We just configure the GitHub integration in the Cloudflare dashboard and skip the custom workflow entirely.
Vercel also handles this automatically if you connect the repository directly. The custom workflow above is for cases where you need more control — running migrations against a preview database, seeding test data, or attaching preview URLs to a specific deployment environment.

Workflow 4: Production Deploy
Production deploys happen only on pushes to main. We use a squash-merge strategy, so each merge commit represents one feature or fix.
# .github/workflows/deploy.yml
name: Deploy Production
on:
push:
branches: [main]
concurrency:
group: production
cancel-in-progress: false
jobs:
check:
name: Pre-deploy Checks
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run check
- run: npm test
deploy:
name: Deploy
needs: check
runs-on: ubuntu-latest
timeout-minutes: 10
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
Key details:
concurrency: productionwithcancel-in-progress: false— If two merges happen in quick succession, the second deploy waits for the first to finish. Never cancel a production deploy mid-flight.needs: check— The deploy job only runs if all checks pass. This is redundant with PR checks, but we want the safety net. A broken merge should not reach production.environment: production— GitHub Environments let you add deployment protection rules (required reviewers, wait timers) and scope secrets to specific environments.
Workflow 5: Database Migrations
Database migrations are the scariest part of deployment. We run them as a separate step, gated by manual approval for production:
# .github/workflows/migrate.yml
name: Database Migration
on:
push:
branches: [main]
paths:
- "supabase/migrations/**"
jobs:
migrate-staging:
name: Migrate Staging
runs-on: ubuntu-latest
timeout-minutes: 5
environment: staging
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link Supabase Project
run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- name: Push Migrations
run: supabase db push
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
migrate-production:
name: Migrate Production
needs: migrate-staging
runs-on: ubuntu-latest
timeout-minutes: 5
environment: production # Requires manual approval
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link Supabase Project
run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF_PROD }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- name: Push Migrations
run: supabase db push
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
The paths filter ensures this workflow only triggers when migration files change. The staging migration runs automatically, and the production migration requires manual approval through GitHub’s environment protection rules.
For more on migration strategies, see our post on zero-downtime database migrations.

What We Intentionally Skip
Docker builds in CI. We deploy to Vercel and Cloudflare. No containers needed. If you deploy to AWS ECS or similar, you will need a Docker build step, but do not add it until you actually need it.
E2E tests in CI. Playwright and Cypress are great tools, but running them in CI is slow (5-15 minutes), flaky (browser timeouts, network issues), and expensive (larger runners). We run E2E tests locally before merge, not in CI. For small teams, the maintenance cost of flaky E2E in CI outweighs the safety it provides.
Code coverage thresholds. Coverage numbers incentivize writing tests for trivial code. We care about testing critical paths — auth, payments, data mutations — not hitting an arbitrary percentage.
Artifact caching beyond npm. Some teams cache build outputs, test results, and various intermediate artifacts. Every cache is a potential source of stale bugs. We cache node_modules and nothing else.
Matrix builds. Testing against multiple Node versions or OS versions is for library authors, not application developers. We pin a single Node version and move on.
Cost
GitHub Actions gives you 2,000 free minutes per month on the free plan, 3,000 on Team. Our workflows across all active projects use about 800 minutes per month. We have never come close to the limit.
The most expensive thing is not the minutes — it is developer time debugging failed workflows. That is why we optimize for simplicity over features. Every conditional, every custom script, every clever caching strategy is a future debugging session.
The Real Takeaway
If you are a small team and you do not have CI yet, start with the lint/check workflow. It takes 10 minutes to set up and catches 80% of the issues that would otherwise reach production. Add tests when you have tests. Add preview deploys when you start doing code reviews. Add production deploy automation when you are tired of deploying manually.
Do not build the perfect pipeline on day one. Build the pipeline you will actually maintain.
If you are building something similar, reach out at [email protected].