.env and .env.example checklist: what to commit, ignore, and rotate
A sound setup has three parts: a committed .env.example that lists every variable the app reads, real .env files kept out of git, and a check that the two have not drifted apart. This checklist covers each part, plus leak response, how dotenv loaders disagree about one file, and where .env files stop being enough.
The checklist
.env.exampleis committed and lists every variable the code reads.- Real env files are ignored, and the rule does not hide the example.
- No live secret is in the example, in a comment, or in git history.
- A pull request that reads a new variable also adds it to the example.
- A new teammate can run the app from the example alone.
- Something checks for drift between the example and the real files.
- Values with
#,$, spaces, or line breaks are quoted and tested with the app’s loader. - A written plan exists for the day a secret leaks.
- Secrets that need sharing, auditing, or rotation live in a secret manager.
What belongs in .env.example
The example is documentation that a program can read. For each variable it gives the name, whether it is required, a safe local value, and where a real one comes from.
- Every name. If the code reads it, list it. A key commented out with
#is a comment to every parser, so a drift check cannot see it. Keep optional keys as real lines with empty values. - Safe defaults only. Write a value only where it works on a developer’s own machine, such as
PORT=3000or a local container’s database URL. - Empty secrets. Leave API keys empty instead of writing
changeme. A checker treats a placeholder as filled in, and a tool that copies defaults starts the new file with it. - A comment per key. Name the owner or the vault that holds the real value, never the value itself.
# .env.example
# Copy to .env and fill in. Real .env files are never committed.
# --- App ---
PORT=3000
LOG_LEVEL=info
# --- Database: local container, safe to commit ---
DATABASE_URL=postgres://app:app@localhost:5432/app_dev
# --- Payments: required. Test-mode key from the team vault, entry "payments-test" ---
PAYMENTS_API_KEY=
# --- Mail: required. Ask the platform owner for a sandbox key ---
MAIL_API_KEY=
# --- Optional: leave empty to turn error reporting off ---
SENTRY_DSN=Empty is not unset
All three loaders compared below turned KEY= into an empty string, not an unset variable. In JavaScript, ?? keeps the empty string, so check required variables with !process.env.NAME.
What must never be committed
- Live env files.
.env,.env.local,.env.production, and any copy pasted into a branch “temporarily”. The dotenv README’s FAQ answers “Should I commit my .env file?” with no, unless the file is encrypted. - Other credentials. Private keys, certificate bundles, service-account JSON, and database dumps with real rows.
- Secrets inside the example. A real key in a comment, or a connection string with a live password.
- Tool configs that hold a token. An MCP client config with a bearer token in
envis the same kind of file. See MCP config file locations. - Public variables. A framework that bundles for the browser has a rule for which variables are public, and every visitor can read them. Learn the rule before you give a key a public name.
Ignore rules that work
These results come from git check-ignore in git 2.43.0, with this .gitignore:
.env
.env.*
!.env.example| Path | Ignored | Why |
|---|---|---|
.env, .env.local, .env.production, .env.test.local | Yes | Exact match, or .env.*. |
apps/web/.env | Yes | A pattern without a slash matches at any depth. |
.env.example, apps/web/.env.example | No | The negation line, the third in the file, includes it again. |
.env.sample | Yes | .env.* matches it too, so add a negation line for .env.sample if that is your example’s name. |
.envrc | No | .env.* needs a dot after .env. Add its own rule if you keep this file. |
Git’s gitignore documentation describes both rules behind the table: a pattern with no slash can match at any level, and a ! pattern includes a path again that an earlier pattern excluded. A path cannot be re-included if one of its parent directories is excluded.
An ignore rule does not untrack a file
A rule only affects files git does not already track. The git check-ignore documentation says tracked files are not subject to exclude rules. We committed a .env, added it to .gitignore, and then edited the file. git status still listed .env as modified, and git check-ignore .env exited with 1 because the file was tracked. Remove it from the index and leave it on disk:
git rm --cached .env
git commit -m "Stop tracking .env"The git rm documentation says --cached removes a path from the index only and leaves the working tree file alone. Run git check-ignore -v <path> to see which rule matches a path, and add --no-index to include tracked files.
Onboard a new teammate
- Copy the example:
cp .env.example .env. - Fill in each empty required key from the source its comment names. Do not ask a colleague to paste their file into chat.
- Start the app. It should print every missing required variable at once and stop.
- Run a drift check against the example. If the example left anything unexplained, fix it in your first pull request.
Step 3 takes a few lines at the top of the entry point:
const required = ["DATABASE_URL", "PAYMENTS_API_KEY", "MAIL_API_KEY"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length > 0) {
console.error(`Missing environment variables: ${missing.join(", ")}`);
process.exit(1);
}Find drift between .env and .env.example
Drift has four shapes. A key is in the example but not your file, so the app breaks on your machine. A key is in your file but not the example, so the next teammate hits it. A key appears twice, and the later line won in every loader we tested. A required key is empty.
The Env file diff checker compares two pasted files for missing, extra, duplicate, and empty keys. It compares key names and emptiness, never values, and it runs in your browser. In a terminal, this works in bash and zsh:
keys() { grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$1" | cut -d= -f1 | sort -u; }
echo "Missing from .env:"; comm -23 <(keys .env.example) <(keys .env)
echo "Not in .env.example:"; comm -13 <(keys .env.example) <(keys .env)It reads plain KEY=value lines only, skips lines with an export prefix or spaces around the =, and never looks at values. CI has no real .env, so there the useful check runs the other way: list the variables the code reads and compare them with the example.
Rotate a leaked secret
- Treat it as exposed. GitHub’s remediation guide says to consider a leaked secret compromised, and that removing it from the code, pushing a new commit, or deleting and recreating the repository does not stop it being exploited. Treat a secret pasted into a chat message, ticket, log, or screenshot the same way, because you cannot recall those copies. Forks and existing clones keep their own copies of the history.
- Issue a new credential, deploy it, then revoke the old one. GitHub’s guide describes generating a replacement first as an option, because changing a secret can break the services that depend on it. If you can see the old credential being misused, revoke it first and accept the outage.
- Read the provider’s access log for use of the old credential between exposure and revocation. Use you do not recognise means widening the response beyond one key.
- Stop tracking the file and add the ignore rule. Rewriting history afterwards is optional cleanup. GitHub says revoking the secret may be enough, and a rewrite cannot reach forks or clones made before it.
- Add a guard, such as secret scanning in CI or a pre-commit hook, and record which credential leaked, for how long, and who rotated it.
Deleting the file is not rotating the key
In our scratch repository, a later commit that untracked .env left the earlier value readable with git show. Rotation is what makes a leaked value worthless.
One file, three loaders
There is no .env standard, so each loader decides what quotes, comments, and $ mean. On 13 October 2026 we ran one sample file through Node.js 22.22.0 with --env-file, python-dotenv 1.2.3, and Docker Compose v5.6.0 (env_file, printed with docker compose config, which writes a literal $ as $$). We did not test the dotenv package for Node, dotenv-expand, or other languages.
| Line in the file | Node.js --env-file | python-dotenv | Docker Compose |
|---|---|---|---|
URL=postgres://user:p#ss@localhost:5432/app | postgres://user:p | Whole URL | Whole URL |
HASH=abc#def | abc | abc#def | abc#def |
EXPAND=${PLAIN}-suffix after PLAIN=hello | ${PLAIN}-suffix | hello-suffix | hello-suffix |
SQ='cost $PLAIN' | Literal $PLAIN | Literal $PLAIN | Literal $PLAIN |
| A value wrapped in backticks | Backticks removed | Backticks kept | Backticks kept |
| The same key twice | Last wins | Last wins | Last wins |
- Quote any value that contains
#. Node.js cut the URL at the#without an error. The Node.js documentation says a#starts a comment unless it sits inside quotes. A password containing#is the usual victim. - Do not rely on
${NAME}expansion unless every tool that reads the file supports it. Single quotes kept it literal in all three, and Docker’s documentation says single-quoted values are literal and that interpolation applies to unquoted and double-quoted values. - A shell variable can beat the file. With
SHELLWINS=from-shellexported andSHELLWINS=from-filein the file, Node.js and python-dotenv’s defaultload_dotenv()both keptfrom-shell, and Docker Compose used the file’s value. A staleexportin your shell profile can hide the.envyou just edited. - These agreed: an
exportprefix, spaces around=, Windows line endings, a#comment after a space, a quoted value that spans two lines, and\nbecoming a line break inside double quotes only.
Where a .env file stops being enough
A .env file starts a local process with the right variables. It is not a secret manager.
- It is plain text. Anything running as your user can read it, and backups of your home folder copy it.
- It has no audit trail or revocation. You cannot see who read a value or who has a copy, so a departure means rotating everything the person held.
- It has no types. Every value is text, and a typo in a name stays silent until the code reads it.
- It is read at startup. Editing it does not change a running process.
For anything shared, audited, or rotated on a schedule, use the secret store your platform or team already runs.
What Rig does with these files
In Rig:
- The
env_diffMCP tool compares key names only and never returns values. It takes the first example file among.env.example,.env.sample, and.env.template, and compares it with keys from.env.local,.env, and the active env profile. fill_env_examplecopies missing keys, with the example’s defaults, into.env.localif the project has one and into.envotherwise. It never overwrites an existing key and waits for an approval click in Rig. It writes the file back as plainKEY=valuelines, so comments and quote marks are not kept, and a quoted value that contains#comes back unquoted. Check the diff afterwards.- Env profiles are plain files at
<project>/.rig/env/{local,staging,production}.env, and Rig creates a.rig/.gitignorethat listsenv/. - Rig masks the values of secret-looking keys: names that contain pass, secret, token, key, auth, or credential.
Key names, not values
env_diff cannot tell you that a value is wrong, expired, or a placeholder.
For more, see Masked env manager, Secrets vault basics, dotenv file, and environment variable.
Frequently asked questions
Should I commit .env.example?
Yes. It holds names, safe local defaults, and comments, and no secrets, so a new teammate can run the app without asking for a file.
Should the example be called .env.example, .env.sample, or .env.template?
They do the same job and none is a standard. Pick one and add the matching ! line to .gitignore, because .env.* ignores every name that starts with .env.. Rig’s env_diff tries .env.example, then .env.sample, then .env.template.
Is it safe to put real secrets in .env.example if the repository is private?
No. Every contributor who clones a private repository gets every value ever committed, and a fork or clone keeps those values even after you delete the file. Keep values empty and put access instructions in the comments.
I added .env to .gitignore but git still shows it. Why?
Git already tracks the file, and an ignore rule only applies to untracked files. Run git rm --cached .env and commit. Earlier commits keep the old content, so rotate any secret that was in it.
How do I check that .env matches .env.example?
Paste both files into the Env file diff checker, or run the comm check above. Both compare key names. Neither can tell you that a value is correct.
Sources
- GitHub Docs: Remediating a leaked secret in your repository
- GitHub Docs: Removing sensitive data from a repository
- Git documentation: gitignore
- Git documentation: git-check-ignore
- Git documentation: git-rm
- Node.js documentation: Environment variables and .env files
- python-dotenv documentation: file format
- Docker Docs: Compose variable interpolation
- Docker Docs: Compose services reference, env_file format
- dotenv README: FAQ on committing .env files
- Rig documentation: Masked env manager (env_diff, fill_env_example, and env profile behaviour confirmed in Rig’s source code)
Run your whole stack from one window
Rig is a Mac app that starts, stops, and watches the processes in your project, with logs and ports in one place. Local use is free, and your source code stays on your Mac.