"It works on my machine." Four words that every engineer has said and every CI pipeline has disproved. It's the most common phrase in software engineering for a reason — it happens all the time, and the underlying cause is more subtle than most people think.
The assumption is that your local environment and the CI runner are "basically the same." They're not. They never are. And the differences aren't random — they fall into three predictable categories.
The Core Lie
Your development machine accumulates state. Every brew install, every nvm use, every globally installed package, every environment variable in your .zshrc — they all shape your local environment in ways your CI config doesn't replicate.
CI runners start from a known image, but that image isn't your machine. It's a standardized environment with:
- Different OS (your Mac vs CI's Ubuntu)
- Different filesystem layout (
/Users/youvs/home/runner) - Different preinstalled tools and versions
- Different locale, timezone, and system settings
- Zero accumulated state from previous runs
Silent Divergence Source 1: Floating Dependency Versions
This is the #1 cause of "works locally, fails in CI" — and it's almost always a pinning problem, not a flaky-infra problem.
The scenario:
- You run
npm installlocally 3 weeks ago. It resolvestypescript@5.4.2 - TypeScript publishes
5.5.0with a breaking change in type checking - CI runs
npm cifrom a fresh environment. If yourpackage.jsonsays"typescript": "^5", CI might resolve a different version than what's in yournode_modules
Wait — shouldn't the lockfile prevent this? Yes, if you commit it. And if CI uses npm ci (which respects the lockfile) instead of npm install (which may update it).
Common traps:
- Lockfile not committed — surprisingly common. Check your
.gitignore npm installinstead ofnpm ci—npm installcan silently update the lockfile.npm cifails loudly if the lockfile is out of date- Different package manager versions — npm 9 and npm 10 can produce different lockfiles from the same
package.json - Floating
FROMtags in Dockerfiles —FROM node:20is a moving target. UseFROM node:20.11.0-slim
Fix:
# Pin everything
# package.json: use exact versions or lockfiles
"typescript": "5.4.2" # exact pin
"typescript": "^5.4.2" # range pin (lockfile protects you)
# CI: always use npm ci, not npm install
- run: npm ci # not npm install
# Docker: pin image digests for maximum reproducibility
FROM node:20.11.0-slim@sha256:abc123...
Silent Divergence Source 2: OS and System-Level Differences
Your Mac and Ubuntu have different:
- Filesystem case sensitivity — macOS is case-insensitive by default. Linux is case-sensitive.
import './MyComponent'works locally but./mycomponent.tsxfails on Linux - Line endings — Windows uses CRLF, macOS/Linux use LF. This can break shell scripts, especially if you're on Windows and push a
.shfile - Locale and timezone — date formatting, sorting, and string comparison behave differently across locales.
LC_ALLandTZaren't set the same way - System libraries — native npm packages (like
sharp,canvas,bcrypt) compile against system libraries. Different OS = different binary = potential incompatibility - Max open files — macOS default
ulimitis often lower than Linux. But CI runners might have specific limits too
The case sensitivity trap deserves special attention:
# This import works on macOS (case-insensitive filesystem):
import { Button } from './components/button' // file is Button.tsx
# But FAILS on Linux CI (case-sensitive filesystem):
# Error: Cannot find module './components/button'
# Correct: import { Button } from './components/Button'
This class of bug doesn't show up until code reaches CI — and the error message ("module not found") doesn't hint at case sensitivity at all.
Silent Divergence Source 3: Environment Variables
Your local .env file, your shell profile, your global git config — they all set environment variables that your CI runner doesn't have.
Common examples:
DATABASE_URL— you have a local Postgres running. CI doesn'tNODE_ENV— you might have this set todevelopmentglobally. CI might run withproductionor noNODE_ENVat allPATHadditions — your.zshrcadds~/.local/binor Homebrew paths that don't exist on CIHOME— different on every platform
How to catch this early:
# Add to your CI config (before_script for GitLab, first step for GitHub Actions)
- name: Print environment
run: |
echo "=== Versions ==="
node -v
npm -v
python3 --version 2>/dev/null || echo "no python"
echo "=== Key vars ==="
echo "NODE_ENV=${NODE_ENV:-unset}"
echo "CI=${CI:-unset}"
echo "HOME=$HOME"
echo "SHELL=$SHELL"
echo "=== Disk ==="
df -h /
Why "It Worked Yesterday" Is Almost Always a Pinning Problem
"It worked yesterday" means something changed between yesterday and today. In CI, the most likely changes are:
- A dependency published a new version (your range pin resolved to a newer version)
- The runner image updated (
ubuntu-latestswitched from Ubuntu 22.04 to 24.04) - A cached artifact expired (clean install pulled newer versions)
- Someone rotated a secret (API key changed, CI wasn't updated)
In 90% of cases, it's #1. The remaining 10% are split between #2 and #4.
The Practical Fix: Pin Everything, Print Versions
# .tool-versions (used by asdf/mise — works locally AND in CI)
nodejs 20.11.0
python 3.12.1
# .nvmrc (Node.js specific)
v20.11.0
# CI config: pin the runner OS
runs-on: ubuntu-22.04 # not ubuntu-latest
# Docker: pin the image tag AND digest
FROM node:20.11.0-slim@sha256:abc123
Version drift is one of the most common root causes we see in pipeline failures — it's boring, but it's the majority case. Daxtack's log analysis flags this pattern specifically because it's so common and so easy to miss when you're staring at a stack trace instead of a diff.