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.
# 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.
| Stage | Order | Catches |
|---|---|---|
| Lint and format | 1 | Style, unused code, suppression drift |
| Type check | 2 | Most refactoring mistakes |
| Unit tests | 3 | Logic, edge cases, data rules |
| Build | 4 | Bundler errors, static generation failures |
| End-to-end | 5 | Integration, accessibility, real browsers |
| Lighthouse / budgets | 6 | Performance regressions |
Two rules that keep this honest:
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.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.
{
"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.
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.
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.
Never cache test results by branch
"Tests passed on this branch before" is not evidence about this commit.
- 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:
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 }}/4fail-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.
| Strategy | Rollback | Cost |
|---|---|---|
| Rolling | Roll forward or redeploy the old build | Simple; slow to undo |
| Blue-green | Flip the router back — seconds | Two environments running |
| Canary | Shift traffic back to zero | Needs real metrics to decide |
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.
Expand
Add the new column, nullable. Deploy. Old code ignores it; new code can use it.
Migrate
Backfill, and write to both old and new. Deploy. Now either version of the app works.
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.
Bundle-size budgets
Measured as real transfer size in a browser, not parsed from build manifests whose shape changes between versions.
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.
Lighthouse thresholds, with achievable numbers
A gate of
CLS === 0fails on a correct implementation, becausefont-display: swapalways leaves a residue. Ours is< 0.001and the measured residue is 0.00016. A threshold nobody can meet teaches a team to ignore the check.Conventional commits, enforced by a hook
Because the changelog and the release version are generated from them.
The blueprint, in order
Build one artefact, tagged with the commit; promote it everywhere
Configuration at start-up, never at build.
Order gates by how fast they fail, and keep the pipeline hermetic
No third-party network calls in the gate.
One command that runs locally and in CI
A red build must be reproducible in one line.
Cache on the lockfile; shard the slow suite with fail-fast off
So one failure does not hide the next three.
Make rollback a routing change
And expand/migrate/contract every schema change so it stays possible.
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.