Back to Blog

CI/CD for Small Teams: GitHub Actions Workflows We Actually Use

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:

  1. Fast feedback — lint and type checks in under 2 minutes
  2. Reliable — no flaky tests, no intermittent failures
  3. Minimal maintenance — use standard tooling, avoid custom scripts
  4. 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 ci instead of npm installci uses the lockfile exactly, which is faster and more reliable.
  • cache: "npm" — Node setup action caches node_modules automatically. 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).

Developer reviewing code with automated CI checks running

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.

DevOps deployment pipeline with stages from build to production

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: production with cancel-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.

Automated test results displayed in a code review interface

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].