CI Passes Locally But Fails in the Pipeline — Diagnosis Guide
A build that works on your machine and fails in GitHub Actions is almost always one of six differences. How to identify which one, and how to stop the class of problem rather than the instance.
The build is green on your laptop and red in the pipeline, on the same commit. The error is usually unhelpful and the temptation is to push seven "fix ci" commits and see what sticks.
There is a faster route. Almost every instance of this is one of six environment differences, and each has a distinguishing symptom.
🔍 Step 0: reproduce the runner locally
Before diagnosing, remove the variable that matters most — your working directory has state the runner does not.
# Clean checkout into a scratch directory, exactly what CI gets.
git clone --depth 1 "file://$PWD" /tmp/ci-repro && cd /tmp/ci-repro
# Lockfile-respecting install, like the pipeline.
pnpm install --frozen-lockfile
pnpm buildIf it fails here, the cause is in the list below. If it passes here and still fails in CI, the cause is the runner itself — jump to sections 5 and 6.
1. 🛠️ Dependency resolution drift
Symptom: the failure appeared with no relevant code change, or a type error inside node_modules.
A plain install is permitted to resolve versions newer than the lockfile. The pipeline then builds code you never tested, and the breaking change arrives from a transitive dependency with no commit to blame.
# ❌ Allowed to resolve newer versions than the lockfile records
- run: pnpm install
# ✅ Fails if the lockfile and manifest disagree
- run: pnpm install --frozen-lockfileImportant
A frozen install failing is good news, not an obstacle. It means the lockfile is out of date, which is a real problem you want surfaced in CI rather than silently resolved into a different build.
2. 🛠️ Filesystem case sensitivity
Symptom: Cannot find module './Button' for a file that is plainly there.
macOS and Windows are case-insensitive by default; the Linux runner is not. import './Button' against button.tsx works on your machine and cannot work on CI.
# Find imports whose casing does not match the file on disk.
git ls-files | sort > /tmp/tracked.txtThe subtle version: renaming a file's case in an editor may leave git holding the original. Check with git ls-files rather than ls, and rename through git:
git mv Button.tsx button.tsx.tmp && git mv button.tsx.tmp button.tsx3. 🛠️ Missing environment variables
Symptom: undefined where configuration should be, or a cryptic failure inside a client library at startup.
Your .env is git-ignored, so the runner has never seen it. Two rules make this loud instead of mysterious:
// Validate configuration at startup, so a missing value names itself.
const Env = z.object({
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
const env = Env.parse(process.env); // fails with the variable's nameAnd keep .env.example in the repo, complete and current. It is the only documentation of what the pipeline needs that cannot drift silently, because a missing key in it becomes a failing build.
Warning
Secrets in a pull-request build from a fork are unavailable by design. If your test suite requires a real API key, it cannot run on fork PRs — split the suite rather than exposing the secret.
4. 🛠️ Timezone, locale, and date formatting
Symptom: date or currency assertions off by hours, or passing only in some months.
Runners are UTC. You are not. A test asserting a rendered date is asserting your offset.
env:
TZ: UTCSet it in the workflow and locally when running tests, so both agree. Then assert on instants rather than formatted strings wherever you can — toISOString() comparisons do not care where the machine is.
5. 🛠️ Memory and CPU limits
Symptom: the job is killed with exit code 137, or the build hangs and times out with no error.
Exit 137 is the OOM killer. A hosted runner typically has considerably less memory than a development machine, and bundlers and type-checkers are the usual casualties.
- run: NODE_OPTIONS="--max-old-space-size=4096" pnpm buildThat is a stopgap. If the ceiling keeps rising, the real cause is usually a build step holding everything in memory at once — an unbounded parallel map over every file, or a source-map setting appropriate for local debugging and not for CI.
6. 🛠️ Test ordering, parallelism, and shared state
Symptom: a test that passes alone, fails in the full suite, and fails differently on reruns.
CI often runs tests in a different order, at a different concurrency, on slower hardware. That exposes three real bugs your local run was hiding:
- Shared state between tests. A module-level cache, a database row, a stubbed global not restored. Each test must set up and tear down its own world.
- Order dependence. Test B passes only because test A ran first. Run with a shuffled seed locally to find these.
- Timing assumptions. A
setTimeoutof 50ms that is ample on your machine and not on a loaded runner. Wait on conditions, never on durations.
# Reproduce ordering and concurrency failures locally.
pnpm vitest run --sequence.shuffle --pool=threadsNote
A test that fails intermittently is reporting a real defect — usually in the test, occasionally in the code. Retrying it until green does not remove the defect; it removes your ability to see it.
✅ Preventing the class, not the instance
[ ] Lockfile-respecting install in every pipeline job
[ ] TZ pinned to UTC in CI and in the local test script
[ ] Environment validated at startup, by name, with .env.example current
[ ] Node version pinned identically in .nvmrc and the workflow
[ ] Tests runnable shuffled and in parallel, locally, on demand
[ ] A documented one-command clean-checkout reproductionThe last line is the one that pays back fastest. Once anyone on the team can reproduce a CI failure locally in thirty seconds, this whole category stops being a pipeline problem and becomes an ordinary bug.
Frequently asked questions
In nearly every case it is an environment difference rather than a code problem: a dependency version resolved differently, a file whose name differs only by case, a missing environment variable, a timezone or locale difference, available memory, or state left behind by a previous local run. Reproducing the CI environment locally with the same lockfile install and a clean checkout identifies which one.
Most commonly a case mismatch. macOS and Windows use case-insensitive filesystems by default, so importing './Button' when the file is 'button.tsx' works locally and fails on the Linux runner. Git may also hold the original casing after a rename, so the fix is git mv rather than renaming in the editor.
Suspect ordering, timing, or shared state before suspecting the runner. CI often runs tests in a different order, in parallel, on slower hardware. Run the suite locally with the same seed and concurrency, and with the machine under load, to reproduce timing-sensitive failures.
Always the lockfile-respecting install — npm ci, pnpm install --frozen-lockfile, or the yarn equivalent. A plain install is allowed to resolve newer versions than the lockfile records, which means the pipeline can build different code than you tested, and can start failing with no commit to blame.
Experiencing a similar issue?
Describe it and get an automated scoping estimate in seconds, with the option to book a free 30-minute diagnostic call.