Find out why it works locally but fails in CI

Snapshot your machine and your CI runner with envdiff, diff runtimes and env vars side by side, and add a check so the same mismatch can't come back.

how-to8 steps

Updated

in short

Run envdiff snapshot on your machine and inside the failing CI job, compare the two files, and turn what you find into an envdiff.yaml that envdiff check enforces.

Tests pass on your laptop and fail in CI, and the log only shows the symptom. The cause is usually something about the environment: a different Node version, a secret that was never added to CI, a variable set only in your shell profile.

Instead of reading two environments by eye, this guide records both as JSON snapshots and diffs them. The example below is a real run: a laptop on Node 22 with a database URL and a Stripe key set, and a CI runner on Node 18 with neither.

Tools used

Before you start

  • envdiff 0.2.1 or newer (earlier versions hide secrets that exist on only one machine and can't read from stdin)
  • A CI job you can add a step to; the examples use GitHub Actions
  • The GitHub CLI (gh) to download the CI snapshot, or the run's Artifacts page

Steps

  1. Step 1: Install envdiff

    The install script downloads the latest release binary for Linux or macOS. With Go installed, go install works too.

    sh
    curl -fsSL https://raw.githubusercontent.com/GBerghoff/envdiff/main/install.sh | sh
    sh
    envdiff --version
    output
    envdiff version 0.2.1
  2. Step 2: Snapshot your machine

    Run this in the same shell you run the tests from, so it sees the same variables. A snapshot records OS and hardware, runtime versions (node, npm, python, go, docker and more), environment variables and network info. Values that look like secrets are replaced with [REDACTED], and the file is written readable only by you.

    sh
    envdiff snapshot -o local.json
    output
    Snapshot saved to local.json
  3. Step 3: Snapshot the CI runner

    Add two steps to the failing job, after its environment is set up and before the tests, so the snapshot sees exactly what the tests see. if: always() keeps them running when an earlier step fails.

    yaml
          - name: Snapshot CI environment
            if: always()
            run: |
              curl -fsSL https://raw.githubusercontent.com/GBerghoff/envdiff/main/install.sh | sh
              envdiff snapshot -o ci.json
          - uses: actions/upload-artifact@v7
            if: always()
            with:
              name: envdiff-ci
              path: ci.json
    sh
    gh run download <run-id> -n envdiff-ci
  4. Step 4: Compare the two snapshots

    compare writes a JSON diff and render turns it into a readable report. A ✗ marks a difference, and (missing) means the variable or runtime isn't there at all.

    sh
    envdiff compare local.json ci.json | envdiff render -
    output
    envdiff — comparing local ↔ ci
    
    RUNTIME
      ✓ git                  2.39.5
      ✓ make                 4.3
      ✗ node                 22.23.3 → 18.20.8
      ✗ npm                  10.9.9 → 10.8.2
      ✓ python               3.11.2
      ✓ svn                  1.14.2
      ✓ yarn                 1.22.22
    
    ENVIRONMENT
      ✗ CI                   (missing) → true
      ✗ DATABASE_URL         [REDACTED] → (missing)
      ✗ HOSTNAME             16ca97ed505e → 37fb0241e586
      ✗ NODE_ENV             development → test
      ✗ NODE_VERSION         22.23.3 → 18.20.8
      ✗ STRIPE_SECRET_KEY    [REDACTED] → (missing)
      ✗ TZ                   America/New_York → (missing)
      ✓ 4 variables match
    
    SYSTEM
      ✓ 6 fields match
    
    ────────────────────────────────────────
    9 different · 15 equal · 0 redacted
  5. Step 5: Read the diff

    Two lines explain this failure. CI runs Node 18 while the project is developed on Node 22, and DATABASE_URL and STRIPE_SECRET_KEY exist only on the laptop: they were never added to the repo's Actions secrets. The secret values stay redacted; you only learn that one side has them and the other doesn't.

    The rest is expected noise. HOSTNAME always differs, CI=true is set by every CI provider, and NODE_ENV and TZ differ on purpose here. Fix the real causes, re-run the job, and compare again until only the expected lines remain.

  6. Step 6: Share the diff in a pull request

    --md renders the same diff as Markdown tables you can paste into a PR description or issue. Secrets show as a lock instead of a value.

    sh
    envdiff compare local.json ci.json -o diff.json
    envdiff render diff.json --md -o envdiff-report.md
    output
    ## Runtime
    
    | Field | local | ci |
    |-------|-------|-------|
    | node | **22.23.3** | **18.20.8** |
    | npm | **10.9.9** | **10.8.2** |
    
    ## Environment
    
    | Field | local | ci |
    |-------|-------|-------|
    | CI | **—** | **true** |
    | DATABASE_URL | **🔒** | **—** |
    | NODE_ENV | **development** | **test** |
    | STRIPE_SECRET_KEY | **🔒** | **—** |
  7. Step 7: Stop it from coming back

    Write down what the project needs in envdiff.yaml and commit it. envdiff init creates a commented template; this one is cut down to the requirements this failure taught us. The fix hints are printed when a check fails.

    sh
    envdiff init
    yaml
    runtime:
      node: ">= 20.0.0"
    
    env:
      required:
        - DATABASE_URL
        - STRIPE_SECRET_KEY
      expected:
        NODE_ENV: test
    
    fix:
      node:
        wrong_version: "use node-version: 22 in actions/setup-node"
      DATABASE_URL:
        missing: "add DATABASE_URL to the repo's Actions secrets and pass it in env:"
  8. Step 8: Check the environment in CI

    Run envdiff check as an early CI step. It exits with status 1 when a requirement fails, so the job stops with a clear reason instead of a confusing test failure. Here it is on the old runner:

    sh
    envdiff check
    output
    envdiff — checking requirements
    
    RUNTIME
      ✗ node                 18.20.8 (requires >= 20.0.0)
    
    ENVIRONMENT
      ✗ DATABASE_URL         (missing) (requires (set))
      ✗ STRIPE_SECRET_KEY    (missing) (requires (set))
      ✓ NODE_ENV             test (requires test)
    
    ────────────────────────────────────────
    1 passed · 3 failed
    
    To fix:
      • node: use node-version: 22 in actions/setup-node
      • DATABASE_URL: add DATABASE_URL to the repo's Actions secrets and pass it in env:

Troubleshooting

  • render fails with open -: no such file or directory: you're on envdiff 0.2.0 or older. Upgrade, or write the diff to a file with compare -o diff.json and render that.
  • permission denied when comparing: snapshots are written readable only by the user who created them. A snapshot taken with sudo or inside a container as root needs sudo chown $USER before you can read it.
  • A secret shows as [REDACTED] on both sides even though the values differ: redacted values aren't compared. --no-redact includes them, so only use it on your own machine and never upload that file.

Tips

  • compare takes more than two snapshots, for example local.json ci.json staging.json, to see which environment is the odd one out.
  • envdiff check --quiet prints nothing and only sets the exit code, which suits a git pre-push hook.
  • Tools your project needs that envdiff doesn't probe by default can be added under custom_runtimes in envdiff.yaml, with the command to run and a regex for the version.

FAQ

Is it safe to upload a CI snapshot as an artifact?
Secret-looking values are redacted by default, but variable names, hostnames, /etc/hosts entries and runtime paths are included, and artifacts are visible to anyone who can read the repo's Actions runs. On a public repository, review the file or drop the artifact step once you've found the problem.
Does envdiff work on macOS and Windows?
Yes. Release binaries are published for Linux, macOS and Windows; the install script covers Linux and macOS, and Windows users can download the binary from the releases page or use `go install`.
Why do HOSTNAME and CI always show up as different?
Every machine has its own hostname and CI providers set `CI=true`, so those lines are expected. Look for differences in runtimes and in variables your code reads.

related