Validate .env files against .env.example before broken config reaches CI or production.
safe-dotenv-check is for teams that already keep a .env.example, but still get burned by missing keys, empty values, wrong value shapes, or environment drift between local, staging, and production.
It gives you:
- missing and empty required key checks
- optional and warning-only env keys
- schema rules like
type=,enum=, andpattern= - environment-specific contracts like
env=production - plain CLI output plus JSON output for CI
- optional descriptions in reports when a key needs context
- next-action suggestions for common failures
- starter manifest generation and manifest sync helpers
- a reusable GitHub Action for repo-level env checks
Quick start:
npx safe-dotenv-checkIf .env.example and .env exist in the current directory, that is enough to run the default check.
Minimal manifest example:
# .env.example
DATABASE_URL= # type=url
OPENAI_API_KEY=
?SENTRY_DSN=
!SLACK_WEBHOOK_URL=
NODE_ENV=development # enum=development|staging|productionTypical failure output:
FAIL .env.production (production)
missing: OPENAI_API_KEY
invalid: DATABASE_URL (type=url), NODE_ENV (enum=development|staging|production)
The same check in JSON for CI:
safe-dotenv-check --example .env.example --env .env.production --env-name production --format jsonIf the main problem is "this should fail before deploy", jump straight to GitHub Action.
If you want the longer version of the problem this solves, read Why .env.example is not enough.
If you want concrete setups, see GitHub Actions guide, Next.js env contract guide, and monorepo env checks.
This came out of a pretty boring failure mode: the repo had .env.example, everyone assumed the env story was handled, and then the deploy still broke because the real runtime env had drifted.
Most of the time the failure was not dramatic. It was small stuff that wasted time:
- one required key missing in staging
- a key present but effectively empty
- an integration that should be there, but should not block release
- a value that exists, but is obviously the wrong shape
I did not want a big config framework for that. I wanted something small and stable enough to run locally or in CI, but strict enough to catch the annoying stuff before it turned into a deploy chase.
That is what safe-dotenv-check is for. It checks a manifest such as .env.example against one or more target .env files and focuses on the things that usually matter when you are actually shipping:
- required keys that must exist
- optional keys that are documented but not enforced
- warning-only keys that should exist but should not block deploys
- empty values for required keys
- schema validation for typed values such as integers, URLs, booleans, JSON, enums, and regex patterns
- unexpected extra keys
- machine-readable JSON output for CI or deployment checks
- a reusable GitHub Action wrapper for repository-level checks
The whole point is to stop .env.example from turning into documentation theater and make it useful as a small, stable env contract for local development, CI, and deploy-time checks.
If you want the short version of what this tool actually does, this is the practical checklist:
- compare one manifest against one or more real env files
- fail on missing required keys
- fail on required keys that exist but are effectively empty
- allow documented optional keys without forcing them everywhere
- warn on recommended integrations without blocking deploys
- validate value shape with
type=,enum=, andpattern= - scope the same key differently across
dev,staging,production, or any env name you use - keep short descriptions next to each key so the manifest stays readable
- emit plain terminal output for humans and JSON output for CI or scripts
- run the same contract through a GitHub Action without duplicating logic
It is intentionally small. The goal is not to replace runtime config libraries. The goal is to catch env drift early enough that you do not burn release time on it.
Nothing special here.
npm install --global safe-dotenv-checkOr run it without installing:
npx safe-dotenv-check
npx safe-dotenv-check .env.production
npx safe-dotenv-check --example .env.example --env .envIf .env.example and .env exist in the current directory, you can run the tool with no flags and it will use those defaults. Positional arguments are treated as target env files, so safe-dotenv-check .env.production is equivalent to safe-dotenv-check --env .env.production.
If the main pain is "this should fail in CI before someone merges or deploys", use the action directly:
jobs:
env-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: eunsujihoon-hub/safe-dotenv-check@v1.3.0
with:
example: .env.example
env_files: |
.env.ci
.env.production
env_names: |
ci
productionInputs:
example: manifest path such as.env.exampleenv_files: newline-separated target env file pathsenv_names: optional newline-separated logical env names, once or once per env fileextra: how to handle keys that exist only in target files:fail,warn, orignoreallow_extra: legacy alias forextra: ignoreshow_descriptions: set totrueto include manifestdesc=/description=text in reportsredact_values: set tofalseonly if JSON reports should include invalid raw valuessummary: set tofalseto skip step summary outputjson_output_path: optional path where the JSON report should be copied
If you want a copy-paste setup guide instead of just the raw action inputs, see docs/github-actions.md.
The basic shape is always the same: point at the manifest, then point at the env file you actually care about.
safe-dotenv-check
safe-dotenv-check .env.production
safe-dotenv-check --example .env.example --env .env
safe-dotenv-check --example .env.example --env .env --env .env.production
safe-dotenv-check --example .env.example --env .env.production --env-name production
safe-dotenv-check --example .env.example --env .env --allow-extra
safe-dotenv-check --example .env.example --env .env --extra warn
safe-dotenv-check --example .env.example --env .env --format json
safe-dotenv-check --example .env.example --env .env --format json --redact-values
safe-dotenv-check --example .env.example --env .env --show-descriptions
safe-dotenv-check --init --env .env.local --out .env.example
safe-dotenv-check --sync-example --example .env.example --env .env.local
safe-dotenv-check --doctor --example .env.exampleIf you do not pass --env-name, the CLI also infers names from files such as .env.production and .env.staging.local.
0: all files passed1: at least one file has missing or empty required keys, invalid values, or unexpected extra keys when--extra failis active2: invalid CLI usage or unreadable files
If a project already has a local env file but no useful manifest yet, generate a starter .env.example with values removed:
safe-dotenv-check --init --env .env.local --out .env.exampleThe command refuses to overwrite an existing output file unless you pass --force.
You can also ask the CLI which keys from a target env file are missing in the manifest:
safe-dotenv-check --sync-example --example .env.example --env .env.localBy default this prints a dry-run list. Add --write to append missing keys to .env.example.
For common projects, --init can add a small starter set:
safe-dotenv-check --init --env .env.local --out .env.example --preset nextjs
safe-dotenv-check --init --env .env --out .env.example --preset vite
safe-dotenv-check --init --env .env --out .env.example --preset nodeUnexpected keys fail by default because drift is usually worth catching:
safe-dotenv-check --example .env.example --env .env.productionFor gradual adoption, report extra keys without failing:
safe-dotenv-check --example .env.example --env .env.production --extra warnTo ignore extra keys entirely, use either form:
safe-dotenv-check --example .env.example --env .env.production --extra ignore
safe-dotenv-check --example .env.example --env .env.production --allow-extraSupported modes are fail, warn, and ignore.
Use --doctor to lint the manifest itself:
safe-dotenv-check --doctor --example .env.exampleIt catches confusing or broken directives such as invalid regex patterns, unknown type= values, empty enums, and unquoted descriptions followed by more directives.
In practice I kept needing three different levels, not just pass/fail:
- required: must exist and must not be empty
- optional: documented only, never fails validation
- warning-only: reported when missing or empty, but does not change the exit code
Example:
# .env.example
DATABASE_URL=
OPENAI_API_KEY=
LOG_LEVEL=info
?SENTRY_DSN=
REDIS_URL= # optional
!SLACK_WEBHOOK_URL=
OTEL_EXPORTER_OTLP_ENDPOINT= # warnOptional keys can be marked in either of these forms:
?SENTRY_DSN=
REDIS_URL= # optionalWarning-only keys can be marked in either of these forms:
!SLACK_WEBHOOK_URL=
OTEL_EXPORTER_OTLP_ENDPOINT= # warnInline comments after an unquoted value are ignored, so API_KEY= # comment is treated as empty. That one is in here because it is an easy way for a key to look present while still being effectively blank.
required- missing: fail
- empty: fail
- invalid by schema: fail
optional- missing: ignored
- empty: ignored
- invalid by schema: fail only when the key is present and non-empty
warning-only- missing: warn
- empty: warn
- invalid by schema: warn
One thing that kept coming up in real projects was that the same key was not always treated the same way everywhere. A key could be optional in local dev, required in production, and irrelevant in CI.
So manifest lines can be scoped with env=:
DATABASE_URL=postgres://localhost/app # type=url desc="Shared database connection"
?SENTRY_DSN= # env=dev desc=Local error tracking only
SENTRY_DSN= # env=production desc=Production error tracking DSN
!SLACK_WEBHOOK_URL= # env=staging,production desc="Deploy notifications"Then pick the active contract with --env-name:
safe-dotenv-check --example .env.example --env .env --env-name dev
safe-dotenv-check --example .env.example --env .env.production --env-name productionRules without env= apply everywhere. Rules with env= only apply when the selected env name matches. Both env=staging,production and env=staging|production are supported.
After the "is the key there?" problem, the next problem was usually "the value is there, but obviously wrong". So schema directives live in the inline comment area of each manifest entry:
PORT=3000 # type=int
APP_URL=https://example.com # type=url
NODE_ENV=development # enum=development|staging|production
FEATURE_FLAGS={} # type=json optional
API_KEY= # pattern=^sk-[a-z0-9]+$Supported directives:
type=stringtype=inttype=integer(alias oftype=int)type=numbertype=booleantype=urltype=jsonenum=value1|value2|value3pattern=<regex>
How the rules behave:
- missing required keys still fail before schema validation
- empty required keys still fail as empty values
- optional keys are validated only when present and non-empty
- warning-only keys report invalid values as warnings instead of failures
type,enum, andpatterncan be combined on the same key
Supported built-in type checks in plain language:
string: always valid as long as the key existsint: whole numbers such as3000or-1number: anything JavaScript can parse as a finite numberboolean:true,false,1,0,yes,no,on,offurl: must parse as a URL with protocol and hostjson: must parse as valid JSON
This part is deliberately small, but useful. Manifest lines can carry a short description so the contract is not just strict, but readable:
DATABASE_URL=postgres://localhost/app # type=url desc="Primary Postgres connection"
OPENAI_API_KEY= # env=production desc=Server-side OpenAI keySupported forms:
desc=...description=...
Quoted descriptions are safest if they contain spaces. Unquoted descriptions also work and are easiest when the description is the last directive on the line.
Directive parsing ignores words that only appear inside desc= or description= text, so prose such as desc="optional for local testing" will stay a description instead of changing validation behavior.
Right now descriptions are stored in the manifest spec and carried into JSON validation entries when a typed rule fails. The immediate use is better contract readability, and it also sets up future doc generation without inventing a separate metadata file.
When you want the report itself to explain what each key is for, pass --show-descriptions:
safe-dotenv-check --example .env.example --env .env.production --show-descriptionsFAIL .env.production
missing: OPENAI_API_KEY - Server-side OpenAI key
empty: DATABASE_URL - Primary Postgres connection
That means the manifest can do double duty:
- strict enough for checks
- readable enough to act as lightweight env documentation
Realistically, this is the kind of manifest line I wanted to write:
DATABASE_URL=postgres://localhost/app # type=url
PORT=3000 # type=int
NODE_ENV=development # enum=development|test|production
FEATURE_FLAGS={"beta":false} # type=json optional
!OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com # type=urlThe output is intentionally plain. The goal is to be readable in a terminal or CI log without making you decode a custom format.
PASS .env
FAIL .env.production
missing: OPENAI_API_KEY
empty: DATABASE_URL
invalid: PORT (type=int), NODE_ENV (enum=development|staging|production)
extra: DEBUG
next:
- add OPENAI_API_KEY to .env.production
- set a non-empty value for DATABASE_URL
- remove DEBUG from the target env file, add it to the manifest, or use --extra warn/ignore
WARN .env.staging
warn-extra: LOCAL_ONLY
warn-missing: SLACK_WEBHOOK_URL
warn-empty: OTEL_EXPORTER_OTLP_ENDPOINT
warn-invalid: OTEL_EXPORTER_OTLP_ENDPOINT (type=url)
If this is feeding another script or CI step, use JSON:
safe-dotenv-check --example .env.example --env .env --format jsonIf the JSON report will be uploaded as a CI artifact, prefer redaction so invalid raw values are not stored:
safe-dotenv-check --example .env.example --env .env --format json --redact-values{
"ok": false,
"example": ".env.example",
"files": [
{
"file": ".env",
"missing": [
"OPENAI_API_KEY"
],
"empty": [
"DATABASE_URL"
],
"invalid": [
{
"key": "PORT",
"value": "abc",
"expected": "type=int",
"description": "Primary API port"
}
],
"extra": [],
"optional": [],
"warning": [
"SLACK_WEBHOOK_URL"
],
"warnMissing": [
"SLACK_WEBHOOK_URL"
],
"warnEmpty": [],
"warnInvalid": [],
"envName": "production",
"ok": false
}
]
}I made this after running into the same pattern enough times that it stopped feeling accidental:
.env.exampleexisted, so everyone assumed the env story was covered- local worked, but staging or CI had one missing key
- sometimes the key existed, but was blank because somebody copied a template line and forgot the value
- sometimes a key was technically present, but the value was wrong enough that the app still booted into a bad state
Most dotenv tooling I tried landed in one of two buckets:
- too loose, so it did not really help at deploy time
- too binary, so it did not fit how real projects usually treat integrations and optional services
In practice, teams usually need three different levels:
- things that must be present
- things that are optional and just documented
- things that are recommended and worth warning about
And once a team starts caring about that, the next question is almost always type or format validation.
So the tool stayed intentionally small, but tried to match how env problems actually show up during work: not as some grand config architecture problem, but as one stupid mismatch that quietly burns 20 to 40 minutes right before a release.
- you already keep a
.env.example, but nobody really trusts it - staging and production drift from local more often than they should
- you want CI to catch env mistakes before deploy time
- you need a little more nuance than just "required" and "not required"
- you want simple type checks without introducing a bigger config layer
- the project only has a couple of env vars and one deploy target
- the team already validates env through a different runtime config system
- you do not actually want
.env.exampleto be a contract, just a loose sample
This repository ignores common secret-bearing files by default because the point is to validate the shape of env config, not encourage people to commit real secrets:
.env.env.local.env.production.local.envrcsecrets/- certificate and private key files such as
*.pemand*.key
Commit only redacted examples such as .env.example. Do not commit real credentials just because the tool checks them.
- generated env reference docs from the manifest
- platform adapters for GitHub Actions secrets and hosted deploy platforms
Bug reports and pull requests are welcome. If you hit a real env mismatch that this tool should catch better, that is especially useful context. See CONTRIBUTING.md.
npm test
npm run pack:check