---
title: "CI/CD Pipelines That Don't Make You Cry"
description: "Practical CI/CD pipeline design for small teams - from GitHub Actions to deployment strategies, built for speed and reliability."
date: 2025-06-08T00:00:00.000Z
category: Engineering
readingTime: "3 min read"
---


The team at HMD Developments has been running CI/CD pipelines since the beginning. Over three years and dozens of projects, we've settled on patterns that work - and learned which popular patterns are overcomplicated for small teams.

## The Core Philosophy

A CI/CD pipeline has one job: get code from a developer's machine to production, safely and quickly. Everything in the pipeline should serve that goal. If a step doesn't prevent bugs, improve confidence, or speed up delivery, remove it.

## The Minimal Pipeline

Every project at HMD Developments starts with this pipeline:

```yaml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    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 build
      - run: npm test
```

Lint, build, test. That's it for the start. Everything else is added when there's a proven need, not preemptively.

## Speed Matters More Than Completeness

A 2-minute pipeline that runs on every push is better than a 20-minute pipeline that developers learn to ignore. Speed determines how often the pipeline actually gets used.

Optimizations that had the biggest impact:

### 1. Dependency Caching

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
```

This single line cut 45 seconds off every run by caching `node_modules`. The cache hit rate across our projects is above 95%.

### 2. Parallel Jobs

```yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
```

Lint, test, and build run simultaneously. Total pipeline time equals the longest job, not the sum of all jobs.

### 3. Skip Unnecessary Runs

```yaml
on:
  push:
    paths-ignore:
      - '**.md'
      - 'docs/**'
```

Changed a README? Don't run the full pipeline. Path filtering prevents wasted compute on changes that can't affect the build.

## Deployment Strategy: Keep It Simple

For most projects, this is our deployment flow:

1. **Push to main** → Build and test in CI
2. **Tests pass** → Auto-deploy to staging (Vercel preview)
3. **Manual review** → Promote to production (Vercel production)

For lower-risk projects (like this personal site), step 3 is automatic too. Push to main, tests pass, production deploys. The entire cycle from commit to live takes under 3 minutes.

## Environment Variables and Secrets

Every project uses a consistent secret management approach:

- **GitHub Actions secrets** for CI variables
- **Vercel environment variables** for runtime configuration
- **`.env.example`** committed to the repo - documents required variables without exposing values
- **No secrets in code.** Ever. Not even "temporarily."

The `.env.example` file is surprisingly important. When a new developer joins, they copy it to `.env.local` and fill in values. No guessing what variables exist.

## Branch Strategy

We use trunk-based development with short-lived feature branches:

```
main (production)
├── feature/add-dark-mode (1-3 days)
├── fix/payment-timeout (hours)
└── feature/new-dashboard (1 week max)
```

Long-lived branches are prohibited. Any branch older than a week needs justification. Merge conflicts increase exponentially with branch age.

## What I Don't Do

### 1. Separate Staging Environment with Its Own Infrastructure

For small teams, a separate staging environment is overhead. Vercel preview deployments serve the same purpose - every PR gets its own isolated URL with the production build pipeline. That's our staging.

### 2. Complex Release Management

We don't use release branches, release trains, or scheduled releases. Ship when it's ready. Feature flags handle incomplete work in production.

### 3. Over-Testing in CI

We run unit tests and critical integration tests in CI. End-to-end tests run on a schedule (not on every push) because they're slow and flaky. A flaky pipeline teaches developers to ignore failures.

## Monitoring the Pipeline Itself

The pipeline is infrastructure. It needs monitoring:

- **Build time trend** - Is the pipeline getting slower? Investigate before it crosses the pain threshold.
- **Failure rate** - Frequent failures indicate either flaky tests or a process problem.
- **Time to recovery** - When the pipeline breaks, how long until it's fixed?

GitHub Actions provides built-in insights for these metrics. Check them monthly. A degrading pipeline degrades the entire development process.

## The Principle

The best pipeline is the one your team actually uses. If developers push directly to main bypassing the pipeline, the pipeline is too slow or too annoying. Fix the pipeline, not the developers.

---

*Setting up CI/CD? Start with lint, build, test - nothing more. Add complexity only when you have a specific problem to solve.*
