---
title: "CI/CD Pipelines That Actually Work"
description: "Practical CI/CD pipeline design for solo projects: GitHub Actions, caching, parallel jobs, and deployment strategies built for speed and reliability."
date: 2025-06-08
updated: 2026-09-21
category: Science
readingTime: "4 min read"
---


I work on my own, which changes what a CI pipeline is for. Nobody else is reading my diffs, so the pipeline is the review. These are the patterns I have settled on, and the popular ones I have decided are not worth the overhead at this size.

## The Core Philosophy

A pipeline has one job: get code from my machine to production safely and quickly. If a step does not prevent bugs, improve confidence, or speed up delivery, it comes out.

## The Minimal Pipeline

Every project starts with this:

```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 is the whole thing at the start. Everything else gets added when there is a proven need, not preemptively.

## Speed Matters More Than Completeness

A two-minute pipeline that runs on every push is better than a twenty-minute pipeline I start working around. Speed decides whether the pipeline is a habit or an obstacle.

### 1. Dependency Caching

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

That one line cut roughly 45 seconds off every run by caching `node_modules`. It is the cheapest win on the list.

### 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, so total pipeline time equals the longest job rather than the sum of all of them. The trade-off is easy to miss: each job gets its own runner, which means checking out the repository again and re-warming the cache each time. For fast steps that setup cost can be larger than the time parallelism saves. Parallelise the jobs that are individually slow, and keep the cheap checks together in one job.

### 3. Skip Unnecessary Runs

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

Changed a README? Do not run the full pipeline. Path filtering stops wasted compute on changes that cannot affect the build.

## Deployment: Keep It Simple

The flow is short by design:

1. **Push** → build and test in CI
2. **Green on main** → build and deploy to production

That is shorter than it used to be, and not in a way I am pleased about. When this ran on Vercel, every pull request got a preview built with the production pipeline, and the preview URL was the part that earned its keep: a real build of the real code at a real address, which tells you more than any amount of staring at the diff.

Moving to Cloudflare Workers cost me that, because previews were a platform feature rather than something I had built. Workers can do the equivalent through `wrangler versions upload`, which returns a preview URL without touching what is live, and wiring that into the pull request workflow is on the list. Until it is, the honest description of this pipeline is that it tests on every push and deploys on green, and that the last look at the real thing now happens after the merge rather than before it.

## Environment Variables and Secrets

The same approach on every project:

- **GitHub Actions secrets** for CI credentials, which for this site is the Cloudflare API token
- **Workers secrets and vars**, set through `wrangler`, for runtime configuration
- **`.env.example`** committed to the repo, documenting required variables without exposing values
- **No secrets in code.** Ever. Not even "temporarily."

`.env.example` earns its place the moment I return to a project after six months and cannot remember which variables it expects.

## Branch Strategy

Trunk-based, with short-lived branches:

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

I keep branches short because merge conflicts grow with branch age, and a branch left alone for three weeks is usually one whose assumptions have quietly gone stale.

## What I Do Not Do

### 1. A Separate Staging Environment

The original reasoning here was that preview deployments already gave every pull request an isolated URL built the way production is built, so a second environment would be a second thing to maintain and a second thing to drift.

The reasoning still holds and the premise currently does not, since I no longer have previews. That is an argument for restoring them, not for building a staging environment: a per-pull-request URL is the cheap version of the thing a staging environment is the expensive version of, and it does not drift because it is thrown away.

### 2. Complex Release Management

No release branches, no release trains, no scheduled releases. Ship when it is ready. Feature flags cover anything half-finished.

### 3. Over-Testing in CI

Unit tests and the critical integration tests run on every push. End-to-end tests run on a schedule, because they are slow and flaky, and a flaky pipeline is one I will eventually start ignoring.

## Watching the Pipeline Itself

The pipeline is infrastructure, so it gets the same attention as any other:

- **Build time trend:** is it getting slower? Investigate before it crosses the annoyance threshold.
- **Failure rate:** flaky tests, or a real process problem?
- **Time to recovery:** when it breaks, how long until it is fixed?

GitHub Actions surfaces all three. A degrading pipeline degrades everything downstream of it.

## The Principle

The best pipeline is the one I never think about. The moment I catch myself wanting to skip it, that is a bug in the pipeline, not in me.
