Skip to content
Secrets7 min read

Should you commit .env files? What belongs in Git

Commit the template, never the values. Which .env files go in Git, what frameworks say, encrypted .env files, and where the real values should live.

Crunu teamPublished 7 min read

Example. Kiteline is a fictional company.
On this page

No. A .env file with real values, such as API keys, database passwords or signing secrets, should not be committed, even to a private repository. What belongs in Git is a template: a .env.example with every variable name, a comment on what it is for, and a placeholder or a harmless default. The one real exception is a file of defaults that contain no secrets, which some frameworks expect you to commit. Encrypted .env files are a third option, and they move the problem to wherever the decryption key lives.

This post sorts the common files into "commit" and "don't", explains what changes once a value is in Git history, and shows a setup where nobody needs a local .env with production values at all. The wider question of how a team should share these files is covered in the guide on sharing .env files with a team.

The short answer, file by file

FileCommit it?Why
.env.exampleYesNames and placeholders only; it documents what the app needs
.env with real valuesNoEvery clone and every copy of history gets the secrets
.env.local, .env.*.localNoMeant for each developer's own overrides and secrets
.env.test with non-secret defaultsUsually yesTests should behave the same for everyone
.env.production with real valuesNoProduction secrets don't belong anywhere a laptop can clone
Encrypted .env (dotenvx, SOPS)PossibleSafe only as long as the key stays out of the repository

The rule underneath the table: Git should hold the shape of your configuration, not its secrets.

Why a private repository isn't enough

"It's a private repo" is a common argument for committing a .env. Private limits who can clone, but cloning is exactly the problem.

Example. Kiteline is a fictional company.

Once a value is committed and pushed, it is in every clone made since, on every developer laptop, in CI caches and build runners, in backups of the hosting service, and in forks. Deleting the file in the next commit removes it from the working tree, not from history: anyone who checks out an older commit gets it back.

Access also drifts. The set of people who can read a repository grows with every new hire, contractor and integration, and it rarely matches the set of people who should hold the production database password. When someone leaves, you can remove them from the repository, but you can't remove the copy they cloned. That turns every secret in the history into something to reissue.

What the frameworks say

The tools agree on the main point and differ on the details.

  • Next.js. The documentation warns that "the default create-next-app template ensures all .env files are added to your .gitignore. You almost never want to commit these files to your repository." For tests it makes an exception: ".env.test file should be included in your repository, but .env.test.local shouldn't, as .env*.local are intended to be ignored through .gitignore" (Next.js: environment variables).
  • Vite. ".env.*.local files are local-only and can contain sensitive variables. You should add *.local to your .gitignore to avoid them being checked into git" (Vite: env variables and modes).
  • dotenv. The README of the Node.js dotenv package answers "Should I commit my .env file?" with "No." and adds that the exception is a file encrypted with dotenvx (dotenv on GitHub).
  • GitHub's .gitignore template for Node ignores .env and .env.*, and explicitly keeps !.env.example (github/gitignore).

The per-framework rules for which files load in which order are on each stack's page, for example environment variables in Next.js.

What counts as a secret

The line between "configuration" and "secret" decides which file a value goes in, so it helps to draw it on purpose.

  • Secret: anything that grants access or proves identity. API keys, database URLs with passwords in them, OAuth client secrets, webhook signing secrets, session and JWT signing keys, private keys.
  • Not secret: values that would do no harm on a public web page. Ports, log levels, feature flags, the name of a region, a public site URL.
  • Public by design, still not for Git: keys that providers mark as safe to expose in a browser, such as a publishable payments key. They aren't secrets, but they differ per environment, so they belong with the other per-environment values rather than hard-coded in history.

When in doubt, treat a value as secret. Moving a value out of the secret list later costs nothing; reissuing one you committed takes real work.

What a good .env.example contains

The template is the part you do commit, so it is worth doing well. It should let a new teammate see every variable the app reads, know where to get each value, and start the app with safe defaults where safe defaults exist.

.env
# Copy to .env.local and fill in. Never commit real values.

# Postgres connection for local development
DATABASE_URL=postgres://localhost:5432/payments_dev

# Stripe test-mode secret key, from the Stripe dashboard (Developers → API keys)
STRIPE_SECRET_KEY=

# Signing secret for the local webhook listener
STRIPE_WEBHOOK_SECRET=

# Safe to share: not a secret
LOG_LEVEL=info

A few habits keep it useful:

  • Empty or obviously fake values for secrets. A placeholder that looks real tends to end up in production.
  • A comment per variable. Where the value comes from matters more than what it is called.
  • Same names as the real files. A template that drifts from what the app reads is worse than none. A linter that compares the two catches drift early; the env linter does this in the browser.

The glossary entries for the .env file and the .env.example file go into the format itself.

A .gitignore that holds up

Ignore every env file by default and allow the template back in:

Text
.env
.env.*
!.env.example

Two things catch people out. First, .gitignore only affects untracked files. Git's documentation says so in its first lines: "Files already tracked by Git are not affected." If a .env was committed before the rule existed, it stays tracked; git rm --cached .env stops tracking it, and the rule keeps it out afterwards (Git: gitignore). The values in earlier commits are still exposed, which is the subject of the next section.

Second, check the rule rather than trusting it:

Shell
git check-ignore -v .env .env.local .env.production

Each file should print the line of .gitignore that matches it. A file that prints nothing is not ignored.

Encrypted .env files: what you gain, where the key goes

A third approach is to commit the file but encrypt the values. dotenvx encrypts each value in place and tells you to "commit your encrypted .env files with your code" (dotenvx quickstart). The dotenv README describes the production flow: encrypt .env.production, put the private key from .env.keys on the server, for example as DOTENV_PRIVATE_KEY_PRODUCTION, and commit the encrypted file. SOPS works on the same idea with age, PGP or cloud KMS keys.

What you gain is real. Configuration is versioned next to the code that uses it, changes go through review, and a stolen copy of the repository is useless without the key.

What you take on is also real:

  • The key is the secret now. Every developer and every machine that needs the values needs the private key, and it has to travel to them some other way.
  • One key opens a whole file. Anyone with the production key reads every production value in it.
  • Removal is hard. When someone with the key leaves, the old key still opens every earlier commit. The post on removing someone from git-crypt, SOPS or Ansible Vault walks through why.

For a small team that already lives in Git, that trade can be worth it. Just count the key as a secret with its own sharing and offboarding plan.

If you already committed one

Assume every value in it is exposed from the moment it was pushed, and reissue those values at their providers before you clean up history. Rewriting history first feels productive but leaves the old values working. The order, the tools and what scanners catch are in a .env file leaked into Git.

Keeping real values out of the working directory

The cleanest answer to "should I commit my .env?" is to make the question rarely come up: keep the template in Git and the values somewhere that hands them to the app when it starts.

Example. Kiteline is a fictional company.

In Crunu, you drag the existing .env files, or whole folders, onto an environment such as web-app / development. Crunu shows the variable names it found, without values, and the path where each file belongs, then stores them encrypted on your device; the server holds the ciphertext and metadata such as names, not the values. Each teammate gets only the environments they have a role on, so a developer can read development without holding the production key.

To run the app, a developer signs in once and starts it through the agent, which passes the variables in the process environment instead of writing a file:

Shell
crunu login
crunu unlock
crunu run --env web-app/development -- npm run dev

crunu run loads the files marked Load as environment variables (crunu run). If a tool insists on a file, crunu pull writes the environment's files to their paths with permissions 0600. Either way, the program and anything it starts can read the values, so this keeps secrets out of Git and out of chat, not out of the app.

The .env.example stays in the repository. It is still the best documentation of what the app needs.

Sources