On this page
  1. Why does a build pass locally but fail in CI?
  2. The differences that break builds, in order of likelihood
  3. The other four, and how to check each
  4. How to close the gap for good
  5. Where AI coding assistants get this wrong
  6. Checklist
  7. FAQ
    1. What is the most common reason CI fails but local passes?
    2. Why does a file import work on my Mac and fail in CI?
    3. How do I reproduce a CI failure locally?
    4. Why does the same build pass sometimes and fail others?
  8. Related topics
  9. Sources
concept

Why Does My Build Pass Locally but Fail in CI?

The nine differences between your laptop and a CI runner that break builds: lockfiles, runtimes, case sensitivity, environment, caching, and hidden state.

Quick answer

  • CI runs on a clean machine with none of your local state, so any dependency on something installed or configured by hand fails there.
  • The most common cause is a lockfile or manifest that was not committed, so CI resolves different versions than you have.
  • The second is the runtime version: your machine’s Node, Python, or Go differs from the runner’s, often because the version is not pinned.
  • The third is the filesystem: Linux runners are case-sensitive, so an import that works on macOS can fail in CI.
  • Reproduce it by building in a container from a clean checkout, which is the same environment CI uses.

Why does a build pass locally but fail in CI?

Because CI is a different machine with a different history. Your laptop has packages installed globally, environment variables exported in a shell profile, build caches from previous runs, and a filesystem that was populated by months of work. A CI runner starts from an image and installs what the pipeline declares. Everything the pipeline does not declare is missing, and everything that depended on your machine’s state breaks.

That framing is more useful than a list of causes, because it tells you what to compare: the two environments, not the two error messages. The error is a symptom; the difference in environments is the cause. Most of the time, the difference is one of nine things below, and each has a specific check.

The differences that break builds, in order of likelihood

  1. Uncommitted lockfile or changed manifest. If package-lock.json, pnpm-lock.yaml, poetry.lock, or an equivalent is gitignored or not committed, CI resolves fresh versions and can pick up a breaking release. Check that the lockfile is tracked and that CI installs with the lockfile-enforcing command rather than a plain install.

  2. Unpinned runtime. A .nvmrc, .python-version, engines field, or explicit version in the pipeline is what makes your machine and the runner agree. Without one, the runner uses its default and a newer or older runtime breaks a dependency or a language feature. Pin it in configuration, not in a README.

  3. Case sensitivity. macOS and Windows filesystems are usually case-insensitive; Linux runners are not. import utils will find Utils.py locally and fail in CI. This class of error is invisible on your machine by construction.

  4. Environment variables. Anything local that came from a shell profile, an untracked .env, or a secrets manager is absent in CI. The pipeline has to define it, and it has to define it for every step that needs it, not just the build step.

  5. Hidden local state. A global CLI, a database that happens to be running, a populated cache directory, or a generated file that is gitignored but present locally. If CI’s clean checkout does not produce it, the build is relying on something that is not in the repository.

The other four, and how to check each

  1. Native modules and platform. A dependency compiled against your local architecture or libc will not load on a different runner image. The fix is to build inside the same base image you deploy to, or to use prebuilt binaries for the target platform.

  2. Timezone, locale, and clock. Tests that format dates or sort strings can pass in one locale and fail in another; CI usually runs UTC with a minimal locale. Setting TZ=UTC and an explicit locale in the pipeline removes the ambiguity.

  3. Caching. A stale cache can make local builds pass with old artifacts and CI builds fail from scratch — or the reverse, where a cached CI layer hides a missing dependency until the cache turns over. When a failure looks impossible, disable caching and rebuild both cleanly.

  4. Test ordering and parallelism. Tests that share state may pass when run in one order and fail when the runner parallelises or randomises them. This shows up as a single flaky test rather than a build error, and the fix is to isolate the shared state.

# Reproduce CI locally: clean checkout, in the same image, no local state.
git clone . /tmp/clean && cd /tmp/clean

# Use the same runtime the runner uses (example: Node).
node --version            # compare with the version in CI logs

# Install exactly what CI installs — lockfile enforced, no cache.
npm ci --no-audit --no-fund   # or: pnpm install --frozen-lockfile

# Run in a container from the same base image, so filesystem case
# sensitivity, locale, and platform match the runner.
docker run --rm -v "$PWD":/app -w /app -e TZ=UTC node:20-bookworm \
  sh -c 'npm ci && npm run build'

How to close the gap for good

Make the pipeline declare everything. Pin the runtime version, commit every lockfile, install with the lockfile-enforcing command, and define environment variables explicitly in the pipeline rather than relying on defaults. Then commit a container image definition or a version file that developers use too, so the local environment is derived from the same declaration as CI rather than assembled by hand.

Add a step that catches the most common class early: run the build in a clean container as part of your pre-push routine, or keep a lint rule that enforces consistent import casing. Both are cheap, and both turn a CI failure into a local one, which is where the feedback loop is fastest.

Where this bites vibecoders

Generated code frequently assumes an environment without saying so: it reads an environment variable that is only defined in your shell, imports a module whose casing only resolves on a case-insensitive filesystem, or installs a dependency without updating the lockfile. None of these are visible in a local run. The first time you add CI, expect to find several, and treat each as the pipeline asking you to declare something you had been doing by hand.

Where AI coding assistants get this wrong

  • Installing dependencies without committing the updated lockfile.
  • Importing modules or files with casing that only resolves on a case-insensitive filesystem.
  • Reading an environment variable with no default and no documentation that it is required.
  • Assuming a globally installed tool exists on the runner.
  • Writing tests that depend on the local timezone or locale.
  • Debugging the CI error before comparing the two environments.

Checklist

  • Confirm the lockfile is committed and CI installs with it enforced.
  • Pin the runtime version in a tracked file and use it in CI.
  • Check imports and file paths for casing mismatches.
  • Declare every environment variable the build needs in the pipeline.
  • Run the build in a container from a clean checkout to reproduce.
  • Set TZ and locale explicitly in CI.

FAQ

What is the most common reason CI fails but local passes?

A dependency installation difference, usually a lockfile that was not committed or a plain install that ignores it. CI resolves versions from scratch and picks up newer releases, which can break the build. Installing with the lockfile-enforcing command both locally and in CI removes most of this class.

Why does a file import work on my Mac and fail in CI?

Because macOS is usually case-insensitive and Linux runners are case-sensitive. An import of utils resolves to Utils.js locally and finds nothing in CI. Check that the casing in every import matches the filename exactly; the failure will not reproduce locally, so it has to be caught by inspection or a lint rule.

How do I reproduce a CI failure locally?

Clone into a clean directory, install with the lockfile enforced, and run inside a container built from the same base image as the runner. That removes local caches, global packages, and case-insensitive filesystem behaviour — the three things that most often make a local run pass when CI does not.

Why does the same build pass sometimes and fail others?

That is usually a cache or a shared-state problem rather than a version difference. A CI cache that occasionally invalidates, or tests that depend on each other’s order when the runner parallelises them, produces intermittent failures. Disable the cache for one run, and run the tests serially, to tell the two apart.

Sources

Share: