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.
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
Step 1: Install envdiff
The install script downloads the latest release binary for Linux or macOS. With Go installed,
go installworks too.shcurl -fsSL https://raw.githubusercontent.com/GBerghoff/envdiff/main/install.sh | shshenvdiff --versionoutputenvdiff version 0.2.1
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.shenvdiff snapshot -o local.jsonoutputSnapshot saved to local.json
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.jsonshgh run download <run-id> -n envdiff-ciStep 4: Compare the two snapshots
comparewrites a JSON diff andrenderturns it into a readable report. A ✗ marks a difference, and(missing)means the variable or runtime isn't there at all.shenvdiff compare local.json ci.json | envdiff render -outputenvdiff — 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
Step 5: Read the diff
Two lines explain this failure. CI runs Node 18 while the project is developed on Node 22, and
DATABASE_URLandSTRIPE_SECRET_KEYexist 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.
HOSTNAMEalways differs,CI=trueis set by every CI provider, andNODE_ENVandTZdiffer on purpose here. Fix the real causes, re-run the job, and compare again until only the expected lines remain.Step 6: Share the diff in a pull request
--mdrenders the same diff as Markdown tables you can paste into a PR description or issue. Secrets show as a lock instead of a value.shenvdiff compare local.json ci.json -o diff.json envdiff render diff.json --md -o envdiff-report.mdoutput## 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 | **🔒** | **—** |
Step 7: Stop it from coming back
Write down what the project needs in
envdiff.yamland commit it.envdiff initcreates a commented template; this one is cut down to the requirements this failure taught us. Thefixhints are printed when a check fails.shenvdiff inityamlruntime: 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:"Step 8: Check the environment in CI
Run
envdiff checkas 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:shenvdiff checkoutputenvdiff — 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
renderfails withopen -: no such file or directory: you're on envdiff 0.2.0 or older. Upgrade, or write the diff to a file withcompare -o diff.jsonand render that.permission deniedwhen comparing: snapshots are written readable only by the user who created them. A snapshot taken withsudoor inside a container as root needssudo chown $USERbefore you can read it.- A secret shows as
[REDACTED]on both sides even though the values differ: redacted values aren't compared.--no-redactincludes them, so only use it on your own machine and never upload that file.
Tips
comparetakes more than two snapshots, for examplelocal.json ci.json staging.json, to see which environment is the odd one out.envdiff check --quietprints 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_runtimesinenvdiff.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.