CI/CD is a habit, not a pipeline

For years I treated continuous integration like flossing advice from my dentist. I nodded, agreed it was probably good for me, went home, and did nothing about it.

CI/CD sounded like something for companies with a platform team and someone whose whole job was babysitting the build. I was shipping fine without it. Or so I told myself.

Twenty years in, I know what that "fine" cost me. The Friday deploy that broke something nobody caught until Monday.

The "works on my machine" fights that traced back to a dependency I had installed globally two years earlier and forgotten about.

Mostly it cost me the quiet anxiety of every push to production. I was the pipeline. And I am not a reliable machine at 11 p.m.

What finally changed my mind was not a tool. It was realising I had the whole concept backwards.

What I actually got wrong

I thought CI/CD was infrastructure. Something you stand up and maintain, like a database or a reverse proxy. It is really a habit with some infrastructure attached.

For years I built a big chunk of something, then tested and shipped it at the end. Testing was a phase. Deploying was an event I braced for.

The shift is to treat every change as already shippable. Small, verified, ready to go the moment you decide. The YAML and the runners just enforce the habit so you cannot slip back.

It helps to be precise, because even good explanations smear the three terms together. They stack:

  • Continuous integration: you merge often, and an automated build verifies each merge, so integration problems surface in minutes instead of festering for weeks.

  • Continuous delivery: your software is always releasable. Any change that passes could ship at the push of a button. Whether it does is a business call, not a scramble.

  • Continuous deployment: you remove the button. Every change that goes green ships automatically, with no human gate.

Delivery is being able to ship at any moment. Deployment is actually shipping every change. You want the first one long before the second.

Most teams, and every solo developer, should live at continuous delivery a good while before taking their hands off the wheel.

None of this is new, which stung a little when I learned it. Kent Beck formalised CI in the nineties as part of Extreme Programming, a rejection of the multi-month "integration phase" that used to sink projects.

People have pushed "integrate early and often" for about as long as I have written code. I just was not listening.

A pipeline is just the same questions, every time

Beginners bounce off CI/CD the first time they open a real workflow file. A wall of indentation, colons, and words like runs-on and uses.

The reaction is "I am not ready, I will come back when I am a real engineer." I had that reaction. It is wrong, and it kept me away for years.

Strip the syntax away and a pipeline is embarrassingly simple. A checklist a machine runs for you, same order, every time, never bored, never skipping a step because it is late. Check out the code. Install the dependencies. Run the tests. If anything fails, stop and shout.

Think about how a pilot lands a small plane. They do not trust memory under pressure. They run a written checklist, out loud, top to bottom, every flight, however many thousands of hours in.

Gear down. Flaps set. Fuel on the fullest tank. The checklist is not there because the pilot is careless. It is there because competence is exactly what fools you into skipping step four.

A CI pipeline is that checklist for your code. No single check is clever. The power is that the checks are identical and unavoidable.

You cannot forget the tests at midnight, coffee gone cold, certain this tiny change could not possibly break anything. The machine does not share your optimism. That is the entire point.

Building one that isn't a toy

Let me show you a real pipeline for a Node project, because concrete beats abstract. If you are on GitHub you install nothing. Actions is already in your repository, waiting.

The convention is a .github/workflows folder at the repo root, with a YAML file inside. That leading dot matters. GitHub only looks in that exact path.

This workflow checks out your code, sets up Node, installs dependencies, and runs your tests on every push and every pull request aimed at main.

Plain text
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v7

      - name: Set up Node
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

That is a complete, working pipeline. But three lines get waved past in most tutorials, and they are the difference between a demo and something you would actually trust.

Why npm ci, not npm install. They look interchangeable. They are not. npm install is the friendly desk command: it reads package.json, resolves what fits, and updates your lockfile as a side effect.

That flexibility is poison in CI. You do not want a run quietly resolving to different dependencies than the ones you tested locally.

npm ci does the opposite. It wipes node_modules, installs strictly from the lockfile, and refuses to run at all if package.json and the lockfile disagree.

It is deterministic and loud about drift, which is what you want from a machine built to catch it. npm's own docs say it is for automated environments. Use it there.

Why cache: npm. Installing from scratch on a fresh machine every push gets slow fast. That one line caches npm's download cache between runs, keyed on your lockfile.

When the lockfile has not changed, packages come from cache, not the network. No separate caching step needed for the common case. I left this out early and wondered why every run installed the whole internet.

Why pin the Node version, and to what. Setting node-version: 24 is not fussiness. It makes runs predictable: the same Node every time, so a test that passes today passes tomorrow for the same reasons.

Pin to the current Active LTS, which right now is Node 24, codename Krypton. Worth saying plainly, because it dates a lot of the tutorials out there.

They tell you to pin Node 22, and Node 22 has already dropped to maintenance-only support. Two-year-old walkthroughs are almost always stale.

Same with the actions. Today checkout is on v7 and setup-node on v6. Check the current majors before you copy anyone's file, mine included.

Every job runs on a brand new Ubuntu machine GitHub spins up for that run and throws away. No leftover files, no packages from last Tuesday, no quirks from your laptop.

A clean room, every time. That disposability is quietly one of the best things CI does: it forces your project to declare everything it needs, because nothing is lying around to save you.

The part that actually earns its keep

I have to be honest about something. For a while my pipeline was theatre. It ran, it went green, I felt professional, and it changed nothing about how I worked. A green checkmark that nobody is required to look at is a participation trophy.

It got useful the moment I made a failing build block the merge. On GitHub you do that with a ruleset. In repository settings, under Rules, create a ruleset for main, turn on "Require status checks to pass before merging," and point it at the test job from your workflow.

The older classic branch protection does the same thing. But rulesets are where GitHub is heading: they stack, they enforce across an org, and anyone with read access can see them. I reach for them now.

With that flipped, it snaps into place. Open a pull request that breaks a test, and GitHub will not let you merge. The button goes grey.

It does not matter that it is your own repo and you are the only one who will ever touch it. Your past self, who wrote the tests, gets a vote on what your tired future self can ship.

That is the trade I wanted all along. Not a badge. A bouncer at the door of my main branch who does not care how sure I am.

This is also why a finding from the DORA research is oddly reassuring. The intuition is that shipping faster means more bugs, that speed and stability trade off against each other.

The data says the opposite. The teams that deploy most often are also the most stable, because the discipline that lets you ship safely is what makes shipping often possible.

A gate on your main branch does not slow you down. It is the thing that lets you speed up without flinching.

Two things nobody tells you on day one

A couple of practical notes that took me embarrassingly long to pick up. First, security, and it is easy. In uses: actions/checkout@v7, that v7 is a tag, and tags can move. Compromise an action's maintainer and they can repoint the tag at malicious code, which your pipeline then runs with full access to your repo.

For GitHub's own actions the risk is low. For third-party actions, pin to a full commit SHA instead of a tag, because a SHA cannot be changed under you. Uglier to read, correct once you pull from across the ecosystem.

Second, money, or the lack of it. People assume this kind of automation has a bill attached. For public repositories, GitHub-hosted runners are free and uncapped. For private repositories on the free plan you get a couple thousand build minutes a month included.

That is a lot of npm test runs before you would ever pay a cent. The cost here is measured in minutes of your attention, not dollars.

Why I came around

I run my own infrastructure. My site lives on a server I understand top to bottom, and I like it that way. When something breaks I can see every layer instead of filing a ticket into the void.

For years I assumed that same instinct, the urge to own my stack, was a reason to skip CI/CD. It felt like handing a piece of my judgement to a robot.

I had it backwards. CI/CD is not a robot taking over my judgement. It is my judgement, written down once when I am clear-headed, then applied for me every time after, including when I am not.

The tests I wrote on a good day guard the code I push on a bad one. The pipeline is not ceremony, and it is not just for big teams. It is a habit made unavoidable, and it is why I can push to my own server now without holding my breath.

Start smaller than you think you need to. One workflow file, one job, run the tests on every pull request, and require them to pass before you can merge.

That is the whole habit in four lines of intent. Everything after that, the deployment pipelines, the preview environments, the security scans, is just more checklist.

Decide what "good" looks like once, then make it impossible to skip.