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:
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 testLint, 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
- 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
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 buildLint, test, and build run simultaneously. Total pipeline time equals the longest job, not the sum of all jobs.
3. Skip Unnecessary Runs
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:
- Push to main → Build and test in CI
- Tests pass → Auto-deploy to staging (Vercel preview)
- 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.examplecommitted 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.