DevOps

"Works on My Machine" — Why Your CI Environment Lies to You

Your machine and the CI runner are never the same environment. The 3 silent divergence sources that cause "works locally, fails in CI" — and how to eliminate each one.

D

Daxtack Team

Engineering

8 min read

"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/you vs /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:

  1. You run npm install locally 3 weeks ago. It resolves typescript@5.4.2
  2. TypeScript publishes 5.5.0 with a breaking change in type checking
  3. CI runs npm ci from a fresh environment. If your package.json says "typescript": "^5", CI might resolve a different version than what's in your node_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 install instead of npm cinpm install can silently update the lockfile. npm ci fails 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 FROM tags in DockerfilesFROM node:20 is a moving target. Use FROM 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.tsx fails 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 .sh file
  • Locale and timezone — date formatting, sorting, and string comparison behave differently across locales. LC_ALL and TZ aren'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 ulimit is 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't
  • NODE_ENV — you might have this set to development globally. CI might run with production or no NODE_ENV at all
  • PATH additions — your .zshrc adds ~/.local/bin or Homebrew paths that don't exist on CI
  • HOME — 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:

  1. A dependency published a new version (your range pin resolved to a newer version)
  2. The runner image updated (ubuntu-latest switched from Ubuntu 22.04 to 24.04)
  3. A cached artifact expired (clean install pulled newer versions)
  4. 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.

CI/CDDevOpsEnvironment MismatchDebuggingDependency ManagementGitHub ActionsDocker

Debug CI/CD failures in 30 seconds

Daxtack uses AI to automatically analyze your build logs, find the root cause, and suggest fixes — right in your pull request.

Related Articles