I put off setting up proper CI/CD for a side project turned small startup for far too long, deploying by SSHing in and running git pull like it was 2012. Building the pipeline out with GitHub Actions took an afternoon once I actually sat down to do it, and most of that afternoon went to mistakes worth writing down so the next person, probably future me on a different project, doesn't repeat them.
Every workflow lives under .github/workflows as a YAML file, and I started with two separate files rather than one monolithic pipeline: ci.yml running on every pull request, and deploy.yml running only on pushes to main after CI passes. Keeping these separate meant I could iterate on deployment logic without re-triggering the full test suite every time, and it made the Actions tab genuinely readable instead of one giant job log.
name: CI
on:
pull_request:
branches: [main]
jobs:
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 test
The cache: npm option on setup-node handles the common case, but I hit a wall on a monorepo with multiple package.json files where the default cache key didn't account for all of them. Explicitly setting cache-dependency-path to a glob covering every workspace's lockfile fixed cache misses that were silently making every CI run slower than it needed to be, and I only found this by comparing run times before and after in the Actions insights tab.
Once the basic pipeline worked, testing against multiple Node versions with a matrix strategy caught a compatibility issue that only showed up on Node 18 in a dependency's native binding, something a single-version pipeline would never have surfaced. The matrix syntax is simple enough that there's no good reason to skip it on a library meant to run across environments you don't fully control.
Storing deployment credentials as repository secrets is the obvious first step, but the part I underused for too long was environment protection rules, requiring manual approval before a workflow can deploy to a production environment. Setting this up meant a bad pull request merge couldn't auto-deploy to production without someone consciously clicking approve, which caught a bug in staging exactly once before it became a real incident.
Once I had three or four repositories with near-identical CI steps, copy-pasting YAML between them turned into its own maintenance burden. Extracting a reusable workflow with workflow_call, then referencing it from each repo with the specific inputs it needed, cut the duplication down significantly and meant a fix to the shared test-and-lint logic only needed to happen in one place.
Pushing several commits in quick succession to the same branch used to queue up a pipeline run for every single push, most of which were obsolete before they finished. Adding a concurrency group keyed on the branch name, with cancel-in-progress set to true, meant only the latest push on a branch actually runs to completion, which mattered more for CI minutes than I expected before checking the billing dashboard.
The marketplace has an action for nearly everything, but I've become more conservative about which ones I pin by SHA versus by tag after seeing supply-chain concerns raised about compromised marketplace actions elsewhere in the ecosystem. Official actions from GitHub itself, actions/checkout and actions/setup-node, I trust by version tag; anything less established gets pinned to a specific commit SHA.
The default failure notifications, an email per failed run, became background noise within a week and started getting ignored, which defeats the point. Routing failures to a dedicated Slack channel via a webhook step, but only for pushes to main rather than every pull request iteration, cut the volume down to signals that actually warranted attention, and the team's response time to a broken main branch improved noticeably once the noise stopped drowning it out.
GitHub-hosted runners covered everything except one integration test suite that needed access to an internal network resource behind a VPN. Standing up a self-hosted runner for just that job, while keeping everything else on GitHub-hosted infrastructure, avoided the larger operational commitment of self-hosting the whole pipeline while still solving the one case that genuinely needed it. This is a narrower use case than most self-hosted runner tutorials assume, and it's worth resisting the urge to self-host more than the specific job that requires it.
The deploy workflow initially had no rollback path beyond manually re-running an old commit's workflow by hand under pressure, which is exactly the wrong time to be figuring out a process. Adding a separate rollback.yml that accepts a specific commit SHA or tag as an input and re-runs the deploy steps against it turned a stressful incident-response scramble into a single button press in the Actions UI, tested ahead of time rather than improvised during an actual outage.
GitHub Actions doesn't require a dedicated DevOps hire to get right for a small to mid-size project, but the defaults you get from copy-pasting a quickstart YAML file leave real gaps around caching correctness, deployment approval gates, and workflow duplication that only show up once you've been running the pipeline for a few months. Building it deliberately, even slowly, beats bolting these fixes on after an incident.