My First Real GitHub Actions Pipeline, and Everything I Got Wrong Building It

By James Nguyen Updated September 24, 2026
My First Real GitHub Actions Pipeline, and Everything I Got Wrong Building It

I'd been manually running tests and deploying from my own laptop for months on a small side project, right up until I pushed a change with a failing test straight to production because I'd forgotten to run the suite before deploying, a mistake that took down the app for about twenty minutes on a Saturday. Building an actual CI pipeline had been sitting on my someday list for months, and that outage moved it to the top immediately.

Starting with the absolute simplest possible workflow

Rather than trying to build a full test-and-deploy pipeline immediately, I started with a workflow that did nothing but run the test suite on every pull request, wanting one genuinely working piece before adding any complexity on top of it.

name: CI
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

The mistake that cost me an hour before I understood npm ci versus npm install

My first version used npm install instead of npm ci, and it worked fine locally but produced a subtly different dependency tree in the CI environment than what I'd tested against, causing a test to fail in the pipeline that passed fine on my machine. Switching to npm ci, which installs exactly what's in the lock file rather than potentially updating it, fixed the mismatch immediately once I understood the actual difference between the two commands.

Caching dependencies, since every run was reinstalling from scratch

Each pipeline run was reinstalling every package from zero, taking a genuinely frustrating couple of minutes before tests even started. Adding a cache step keyed to the lock file's hash cut that install time down dramatically on every run after the first.

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

Adding a deploy step, gated behind tests actually passing

Once the test workflow felt solid, I added a second job specifically for deployment, configured to only run on pushes to the main branch and only after the test job succeeded, the exact safeguard that would have stopped my Saturday outage from ever reaching production in the first place.

deploy:
  needs: test
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: ./scripts/deploy.sh
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Secrets management, and a mistake I nearly made publicly

I almost hardcoded my deploy token directly into the workflow file during an early draft, catching myself only because I was about to commit it to a public repository. GitHub's repository secrets exist specifically for this, encrypted values referenced by name in the workflow rather than ever appearing in the file itself, and I now treat any credential in a workflow file as an automatic red flag during my own review before committing.

Branch protection rules, the piece that actually enforces the whole system

Having a passing CI check meant nothing if I could still merge a pull request with a failing test manually overridden, so I enabled a branch protection rule requiring the test job to pass before merging is even allowed, turning the pipeline from a helpful suggestion into an actual, enforced gate.

A flaky test that taught me to distinguish real failures from noise

One test failed intermittently in CI but never locally, eventually traced to a race condition where an async operation sometimes hadn't resolved before an assertion ran. Rather than ignoring the flakiness or wrapping it in a retry, I fixed the actual race condition with a proper await, since I'd already read enough about flaky test suites to know that tolerating them quietly trains a whole team to stop trusting CI failures at all.

A matrix build, added once we needed to support two Node versions

A dependency upgrade required us to confirm our app still worked on both our current Node version and the next one before committing to the migration, and a matrix build let the same workflow run against both versions in parallel rather than writing two nearly identical jobs by hand.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This caught a real incompatibility in a dependency that only surfaced on the newer Node version, something a single-version pipeline would have missed entirely until well after we'd already upgraded everyone's local environment.

A status badge, a small addition that made the pipeline's value visible

Adding a workflow status badge to the project's README, showing a live passing or failing indicator pulled directly from the latest run, turned an invisible background process into something the whole team actually saw every time they opened the repository, a small addition that made the pipeline's existence and current health obvious at a glance rather than something you'd only think about after an outage.

![CI](https://github.com/org/repo/actions/workflows/ci.yml/badge.svg)

What I'd tell someone still deploying manually from their laptop

That Saturday outage was a genuinely avoidable mistake, and building this pipeline, badly at first, then incrementally better, has made it structurally impossible for me to repeat it the same way again. Starting with the smallest possible working piece, rather than trying to design a complete pipeline up front, is what actually got me from a manual, error-prone process to a real safety net in an afternoon instead of never finishing at all.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles