Skip to content

Writing

From commit to production: a CI/CD blueprint that stays fast as it grows

The pipeline shape that keeps deploys boring — one build artefact promoted rather than rebuilt, gates ordered by how fast they fail, and a rollback that is a routing change rather than a redeploy.

6 min read

A deployment pipeline has one job: make shipping so unremarkable that nobody schedules it. Every property below exists to serve that, and each one is the fix for a specific way pipelines stop being boring.

Build once, promote the same artefact

The most important rule, and the most commonly broken one.

Build one artefact, tag it with the commit, and promote that exact artefact through every environment.

yaml
# Build once.
- run: yarn build
- run: docker build -t $REGISTRY/app:${{ github.sha }} .
- run: docker push $REGISTRY/app:${{ github.sha }}
 
# Promote. No build step anywhere below this line.
- run: deploy --env staging  --image $REGISTRY/app:${{ github.sha }}
- run: deploy --env production --image $REGISTRY/app:${{ github.sha }}

Everything environment-specific becomes configuration read at start-up, not at build. That is a constraint on how you write the application, and it is worth accepting.

Order gates by how fast they fail

Feedback time is the property that determines whether people run the pipeline before pushing. Put the cheap checks first and fail the whole run on the first failure.

StageOrderCatches
Lint and format1Style, unused code, suppression drift
Type check2Most refactoring mistakes
Unit tests3Logic, edge cases, data rules
Build4Bundler errors, static generation failures
End-to-end5Integration, accessibility, real browsers
Lighthouse / budgets6Performance regressions
Roughly the order we use. The point is not the exact timings — it is that a failure surfaces in seconds rather than after everything has run.

Two rules that keep this honest:

  1. The pipeline must be hermetic

    Nothing in it may reach a third party. A check that depends on somebody else's DNS fails on a train, behind a proxy, and on a loaded machine — and none of those say anything about your code. On this project an end-to-end test requested real client links and failed with ENOTFOUND; outward links are now checked by a separate on-demand command.

  2. Timeouts mean load, failed assertions mean a defect

    That distinction is how you tell a real failure from a busy runner. Four stray dev-server processes on one machine once turned a four-minute suite into thirty-six minutes and twenty failures — every one a timeout, none an assertion.

Local and CI run the same commands

If CI runs something a developer cannot run, CI failures become somebody else's problem.

json
{
  "scripts": {
    "lint": "biome check .",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "e2e": "playwright test",
    "validate": "yarn lint && yarn type-check && yarn test && yarn build && yarn e2e"
  }
}

CI runs yarn validate. So does a developer, before pushing. There is no CI-only script and no CI-only configuration, which means a red build is reproducible in one command.

Caching: correct first, fast second

A cache that returns a stale result is worse than no cache, because it produces a green build for code that does not work.

  1. Key on the lockfile, not the branch

    Dependencies change when the lockfile changes. Anything else is a cache that is either over-eager or useless.

  2. Cache the build's own cache directory too

    Modern bundlers and type checkers keep incremental state. That is often a larger win than the dependency cache.

  3. Never cache test results by branch

    "Tests passed on this branch before" is not evidence about this commit.

yaml
- uses: actions/setup-node@v4
  with:
    node-version-file: .nvmrc
    cache: yarn                    # keyed on the lockfile
 
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: build-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
    restore-keys: build-${{ hashFiles('yarn.lock') }}-

Parallelise across jobs, shard the slow one

Lint, types and unit tests are independent — run them as separate jobs. The end-to-end suite is usually the long pole, and it shards cleanly:

yaml
strategy:
  fail-fast: false        # one shard failing should not hide the others
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: yarn e2e --shard=${{ matrix.shard }}/4

fail-fast: false matters more than it looks. With it on, the first shard to fail cancels the rest — so you fix one failure, push, and discover the next. Three round trips instead of one.

Deploy: the rollback determines the design

Design the deploy backwards from how you undo it.

StrategyRollbackCost
RollingRoll forward or redeploy the old buildSimple; slow to undo
Blue-greenFlip the router back — secondsTwo environments running
CanaryShift traffic back to zeroNeeds real metrics to decide
Every strategy is defined by its rollback. Pick the one whose rollback you are willing to perform at 2am.

The canary is only meaningful if something automatic decides. A canary a human watches is a delay with extra steps: define the error-rate and latency thresholds up front, and let the pipeline shift traffic back on its own.

Database migrations are the part that actually breaks

Application rollback is easy. Schema rollback usually is not — which is why migrations have to be backwards compatible for one release.

  1. Expand

    Add the new column, nullable. Deploy. Old code ignores it; new code can use it.

  2. Migrate

    Backfill, and write to both old and new. Deploy. Now either version of the app works.

  3. Contract

    Only once you are certain you will not roll back: drop the old column. Deploy.

Three deploys instead of one, and it is the difference between a rollback being a routing change and a rollback being impossible.

What to enforce, beyond tests passing

A pipeline that only checks correctness lets everything else drift.

  1. Bundle-size budgets

    Measured as real transfer size in a browser, not parsed from build manifests whose shape changes between versions.

  2. Accessibility, at more than one width

    Overflow and scroll-region defects only exist at the width where content actually overflows, which is never the width anyone checks.

  3. Lighthouse thresholds, with achievable numbers

    A gate of CLS === 0 fails on a correct implementation, because font-display: swap always leaves a residue. Ours is < 0.001 and the measured residue is 0.00016. A threshold nobody can meet teaches a team to ignore the check.

  4. Conventional commits, enforced by a hook

    Because the changelog and the release version are generated from them.

The blueprint, in order

  1. Build one artefact, tagged with the commit; promote it everywhere

    Configuration at start-up, never at build.

  2. Order gates by how fast they fail, and keep the pipeline hermetic

    No third-party network calls in the gate.

  3. One command that runs locally and in CI

    A red build must be reproducible in one line.

  4. Cache on the lockfile; shard the slow suite with fail-fast off

    So one failure does not hide the next three.

  5. Make rollback a routing change

    And expand/migrate/contract every schema change so it stays possible.

  6. Enforce size, accessibility and performance, with thresholds that are meetable

    An unmeetable gate is a gate everyone learns to skip.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.