env-xray
v0.1.0
Published
Find ghost variables, orphaned config, wrong defaults, and port conflicts in your .env files. Zero dependencies.
Maintainers
Readme
env-xray
___ _ ____ __ __ ___ __ __ _ _ _
/ _ \ '_ \ \ / /____\ \/ / '__/ _` | | | |
| __/ | | \ V /______> <| | | (_| | |_| |
\___|_| |_|\_/ /_/\_\_| \__,_|\__, |
|___/ Map every environment variable from definition to consumption. Find what's broken before production does.
Zero dependencies. Works with .env, .envrc, docker-compose.yml, GitHub Actions, Makefiles, K8s manifests, and source code in 6 languages.
npx env-xrayWhy This Exists
Every project accumulates environment variables like sediment (and to continue a metaphor, sometimes they contain fossils as well as like, broken pieces of glass that can cut your ass). Layers of .env, .env.defaults, .envrc, .env.local, docker-compose.yml, K8s manifests—each defining some variables, consuming others, and hoping they all agree.
They often don't.
I kept running into this at my day job—variables not matching across layers, services hitting dead endpoints because of port mismatches, cron endpoints unprotected because nobody defined the auth secret. I looked for a tool that could map everything end-to-end and tell me what's broken. Couldn't find one. So I built it.
I ran it against my own production monorepo and it found a service port conflict we'd missed for months—a fallback of http://localhost:8092 in the frontend, but the service ran on 9091. Three features silently hitting a dead endpoint. A one-line fix, but we didn't know it was broken.
What It Finds
| Check | What It Means |
|-------|--------------|
| Ghost (hard) | Variable consumed in code with no fallback and no definition. Will crash or use undefined. |
| Ghost (soft) | Variable consumed with a fallback (\|\| "default") but never formally defined. Won't crash but isn't configured. |
| Orphan | Variable defined in env files but never consumed by code. Dead config. |
| Conflict | Variable defined in multiple files with different values. Which one wins? |
| Stale default | Placeholder value like your-api-key-here that would cause silent failures. |
| Duplicate | Same variable defined twice in the same file. Copy-paste error. |
| Empty | Variable defined with empty value and consumed in code. Will be an empty string at runtime. |
Does It Actually Work?
I ran env-xray against 23 popular open-source projects across 8 languages. Every single one had findings.
| Project | Stack | Vars | Findings | Hard Ghosts | Soft Ghosts | Orphans | |---------|-------|------|----------|-------------|-------------|---------| | supabase | TS/Mixed | 591 | 469 | 258 | 51 | 119 | | posthog | Python/Django | 567 | 535 | 209 | 298 | 23 | | cal.com | TS/Next.js | 348 | 109 | 58 | 25 | 20 | | n8n | TS/Node | 252 | 244 | 128 | 75 | 0 | | mastodon | Ruby/Rails | 246 | 229 | 112 | 96 | 14 | | nocodb | TS/Node | 193 | 189 | 87 | 86 | 15 | | BookStack | PHP/Laravel | 173 | 15 | 0 | 0 | 15 | | twenty | TS/React | 171 | 122 | 43 | 35 | 33 | | medusa | TS/Node | 153 | 139 | 72 | 36 | 5 | | chatwoot | Ruby/Rails | 137 | 82 | 26 | 47 | 5 | | saleor | Python/Django | 118 | 111 | 31 | 80 | 0 | | loki | Go | 110 | 76 | 43 | 0 | 9 | | documenso | TS/Next.js | 106 | 55 | 12 | 1 | 42 | | immich | TS/Svelte | 94 | 82 | 60 | 11 | 11 | | paperless-ngx | Python/Django | 83 | 80 | 44 | 35 | 1 | | strapi | TS/Node | 80 | 64 | 35 | 29 | 0 | | appwrite | PHP/Mixed | 68 | 65 | 10 | 7 | 48 | | monica | PHP/Laravel | 63 | 43 | 0 | 0 | 32 | | maybe | TS/Next.js | 59 | 41 | 26 | 15 | 0 | | hoppscotch | TS/Vue | 55 | 44 | 33 | 4 | 4 | | traefik | Go | 17 | 14 | 7 | 5 | 1 | | minio | Go | 9 | 9 | 8 | 0 | 1 | | typst | Rust | 1 | 1 | 1 | 0 | 0 |
"Hard" ghosts have no fallback—they'll crash or silently use undefined. "Soft" ghosts have fallbacks like process.env.PORT || 3000—they won't crash but the variable is still never formally defined.
BookStack and Monica (both PHP/Laravel) had zero ghosts—all their env vars are properly defined. That's what clean looks like.
What it found
cal.com—CRON_SECRET used in 6 files for auth but never defined in any env file. Cron endpoints silently accept any request. POSTGRES_PASSWORD defined with 3 different values across config files.
twenty—TWENTY_APP_ACCESS_TOKEN referenced 16 times across 7 files, defined nowhere. 11 placeholder values like your_twenty_api_key_here and xxx.
documenso—NEXTAUTH_SECRET defined as "secret" in two places and "${NEXTAUTH_SECRET:?err}" in a third. 42 orphaned variables.
Install
# Run without installing
npx env-xray
# Install globally
npm install -g env-xray
# Add to your project
npm install --save-dev env-xrayUsage
CLI
npx env-xray # Grouped summary (default)
npx env-xray --verbose # Full details for every finding
npx env-xray --md > report.md # Markdown report
npx env-xray --json # JSON output
npx env-xray --ci --severity=error # Fail CI on critical issues| Flag | What it does |
|------|-------------|
| --verbose, -v | Show full details for every finding |
| --json | JSON output (values masked) |
| --markdown, --md | Markdown report |
| --severity=error | Only show errors (hard ghosts, conflicts) |
| --severity=warning | Show errors and warnings |
| --ci | Exit code 1 if errors found (default: always exit 0) |
| --init-ignore | Generate .env-xray-ignore from current findings |
| --local-only | Skip findings from deploy/, .github/, k8s/ directories |
| --exclude=<pattern> | Skip files/directories matching pattern (repeatable) |
| --max-file-size=<bytes> | Skip files larger than this (default: 1MB) |
By default, env-xray shows a grouped summary—type breakdown, top findings by severity, then results organized by directory. Use --verbose for the full list.
CI / GitHub Action
Run env-xray on every PR:
name: Env Check
on: [pull_request]
jobs:
env-xray:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: andymboyle/env-xray@main
with:
severity: errorOr as a one-liner in an existing workflow:
- name: Check env vars
run: npx env-xray --ci --severity=errorProgrammatic API
import { scan, formatTerminal, formatJSON } from 'env-xray';
const result = scan({
rootDir: '/path/to/project',
maxFileSize: 2_000_000, // skip files over 2MB (default: 1MB)
exclude: ['generated/'], // skip directories/files
});
// result.findings — array of Finding objects
// result.variables — Map<string, Variable> of all discovered variables
// result.filesScanned — { envFiles: string[], sourceFiles: string[] }
// result.skippedFiles — files skipped due to size, symlinks, or --exclude
console.log(formatTerminal(result)); // colored terminal output
console.log(formatJSON(result)); // JSON (values masked)How It Works
What it reads
env-xray scans your project in a single pass, categorizing every file it finds:
Variable references (consumption)—source code in 6 languages:
| Language | Patterns detected |
|----------|------------------|
| JavaScript/TypeScript | process.env.VAR, process.env["VAR"], import.meta.env.VAR, const { VAR } = process.env, Zod schemas, envalid |
| Python | os.environ["VAR"], os.environ.get("VAR"), os.getenv("VAR") |
| Ruby | ENV["VAR"], ENV.fetch("VAR") |
| Go | os.Getenv("VAR"), struct tags (envconfig:"VAR", env:"VAR") |
| Rust | env::var("VAR") |
| PHP | getenv("VAR"), $_ENV["VAR"], $_SERVER["VAR"] |
Variable definitions (where values are set):
| File type | What's parsed |
|-----------|--------------|
| .env* files | KEY=value, export KEY=value, quoted values, inline comments |
| Custom .env files | dev-services.env, celery-queues.env, etc. |
| docker-compose.yml | environment: sections, env_file: references (followed and parsed) |
| GitHub Actions | env: blocks at workflow, job, and step level |
| Makefiles | export VAR=value, VAR ?= value, VAR := value |
| Config files | config.yaml, config.json with ${VAR} interpolation |
| K8s manifests | Deployment YAMLs in deploy/, k8s/, kubernetes/ directories |
What it filters
env-xray isn't just grep. It understands context:
.exampleand.templatefiles are excluded from orphan, empty, stale, and conflict detection—they're documentation, not active config${VAR}interpolation in docker-compose is recognized as a reference, not a conflicting literal value- Platform variables (
CI,NODE_ENV,VERCEL_URL,HOSTNAME, etc.) are excluded from ghost detection—they're set by the runtime, not.envfiles - Expected conflicts like
PORTandDATABASE_URLare skipped—they're supposed to differ across services - Tooling directories (
tools/,examples/,test/,fixtures/,sandbox/) are excluded from orphan detection - Fallback detection distinguishes
process.env.PORT(hard ghost) fromprocess.env.PORT || 3000(soft ghost) --local-onlyfilters outdeploy/,.github/,k8s/findings when you only care about local dev--exclude=<pattern>skips specific files or directories entirely
What it protects
- Symlinks are skipped—prevents reading files outside your project root
- Large files are skipped—files over 1MB (adjustable with
--max-file-size) are ignored to prevent OOM on binary files or data dumps - Secret values are masked—all output shows
"http***"instead of raw values, including--json
Ignore File
Create .env-xray-ignore in your project root to skip known false positives:
# These are set by the deployment platform, not in .env files
VERCEL_URL
RAILWAY_ENVIRONMENT
AWS_REGIONOr generate one automatically from your current findings:
npx env-xray --init-ignoreThis creates the ignore file with all current variables, so future runs only catch new issues. Remove lines as you fix them to track progress.
Contributing
Found a false positive? A pattern env-xray misses? Open an issue with the example and we'll fix it.
License
MIT
