Published: · Updated: · by kodewright
Securing Your .env Files: 2026 Best Practices
Keep .env secrets out of git, Docker images and logs, generate them properly, and rotate first when one leaks. With real commands and my own deploy setup.

Securing a .env file comes down to four rules: it never enters git, it never enters a Docker image, every value in it comes from a CSPRNG instead of your keyboard, and when one leaks you revoke the secret before you touch git history. The rest of this guide is the detail behind each rule, with the commands I actually run and the setup this site uses in production.
Where .env secrets actually leak
The classic leak is git add . in a repo without a .gitignore entry. It's still the most common one, and it's not limited to public repos. GitGuardian's State of Secrets Sprawl 2025 counted 23.8 million new secrets pushed to public GitHub in 2024, but also found secrets in 35% of the private repositories it scanned, against 4.6% of public ones. Private repos get cloned to laptops, CI runners and contractors, so "it's private" is not a control.
The second number in that report is the one that should worry you: 70% of the secrets it found leaked in 2022 were still valid. People delete the file, push again, and never rotate the key.

The other two routes are quieter. A Dockerfile with COPY . . and no .dockerignore puts the file into a layer, and anyone who can pull the image can read it. And an error handler or a CI step that prints the environment sends every secret to whatever collects your logs.
A .gitignore that covers every .env variant
Frameworks multiply .env files: .env.local, .env.production, .env.test. Ignore the whole family and whitelist only the template:
.env
.env.*
!.env.example
git check-ignore -v tells you which rule matched, which is handy when a nested .gitignore overrides yours:
$ git check-ignore -v .env.local .env.example
.gitignore:2:.env.* .env.local
.gitignore:3:!.env.example .env.example
The trap is a file that was committed before the rule existed. Git keeps tracking it, and check-ignore stays silent about it because tracked files aren't subject to ignore rules. git rm --cached .env stops tracking it, but the old commit still holds the values:
$ git rm --cached .env && git log --oneline -- .env
d1c1d61 init
If that commit was ever pushed, treat every value in it as public and go to the leak section below. Keep .env.example to variable names and obviously fake values; a real key in the template is the same leak with a different filename.
Keep the file out of your Docker image
A multi-stage build doesn't save you here. The builder stage still receives the whole build context, and a secret that lands in any layer you push is extractable. Add the file to .dockerignore so it never reaches the build context at all:
.env
.env.*
Then pass values at runtime instead of build time. Nuxt makes this easy: anything declared in runtimeConfig can be overridden by a matching NUXT_ environment variable when the server starts, so the built .output contains an empty placeholder, not the key. Most frameworks have the same split between build-time and runtime config. Find out which side your secret is on, because anything read at build time can end up in the bundle.
How I handle secrets for this site
This site is small: one VPS, one container, two secrets (the API key for the contact form's email service and the contact address). The setup is deliberately boring:
- Locally, values sit in
.env, which is in both.gitignoreand.dockerignore.git log --all -- .envon this repo returns nothing, so it has never been committed. - Production values live in
ansible/group_vars/all/vault.yml, encrypted with Ansible Vault. That file is in git, and its first line is$ANSIBLE_VAULT;1.1;AES256; everything after it is ciphertext. - The vault password file is in
.gitignore. It's the single key to everything above, so it never goes near the repo. - On deploy, the Ansible playbook decrypts the vault in memory and passes the values to the container as environment variables. The Docker image itself carries no secrets, and the server holds no
.envfile.
The trade-off I accept: anyone with root on that server can read the values with docker inspect. For a single-person VPS that's fine, because an attacker with root there already owns everything else. With several services, several people, or a compliance requirement, this setup stops being enough, and a secrets manager with per-service access and audit logs earns its cost.
I've also been through the cleanup of the opposite setup. On one project an API key was hardcoded in the source. The fix had three parts: the key moved into .env, the code that contained it was removed, and a new key was generated. The last step is the one people skip, and it's the only one that actually closes the hole.
Generate values, don't type them
A secret someone typed, like myapp-secret-2026, is a guessable password, not a key. Generate every value from a CSPRNG. On the command line:
openssl rand -hex 32 # 256 bits, 64 hex chars
openssl rand -base64 32 # 256 bits, 44 chars
openssl rand -base64 64 | tr -d '\n' # 512 bits for HS512
That tr isn't decoration. OpenSSL 3.0 wraps base64 output at 64 characters, so openssl rand -base64 64 prints two lines, and a pasted two-line value in .env quietly becomes a truncated secret or a parse error, depending on the loader.
| Use | Bits | Hex length | Base64 length |
|---|---|---|---|
| Session or cookie secret | 256 | 64 | 44 |
| HS256 JWT secret | 256 | 64 | 44 |
| HS512 JWT secret | 512 | 128 | 88 |
| AES-256 key | 256 | 64 | 44 |
If you'd rather click, my Secret Key Generator produces 128, 256 or 512-bit keys in hex or base64 from crypto.getRandomValues(), and the key is generated locally in your browser tab without being sent anywhere. Avoid any generator built on Math.random(): V8's implementation is xorshift128+, and its internal state can be recovered from a handful of outputs. A UUID has 122 random bits and is fine as a request ID, but use a proper key for anything that signs or encrypts. The background is in CSPRNG explained.
On a shared machine, also run chmod 600 .env so other local users can't read it.
When a secret leaks: rotate first, clean history second
GitHub's own documentation on removing sensitive data puts revocation first: once the old secret no longer grants access, the history rewrite may not even be necessary. The order I'd follow:
- Revoke or rotate the key at the provider. Deploy the new value.
- Check the provider's access logs from the time of the commit onward.
- Only then decide whether to rewrite history. GitHub now recommends
git-filter-repo2.47 or later with--sensitive-data-removal, and warns that forks keep the commit and cached views stay reachable by SHA until GitHub Support removes them.
Rewriting history before rotating gives you a false sense of safety, because scrapers read new public commits within minutes. Since March 2024, GitHub enables secret scanning and push protection by default on new public repositories owned by personal accounts, which blocks many known key formats at push time. It doesn't recognize a random hex string as a secret, so the .gitignore still does the real work. For API keys you issue yourself, API key management covers prefixes that make leaked keys detectable.
Where each secret should live
| Stage | Where the value lives | How the app gets it |
|---|---|---|
| Local dev | .env, git-ignored and docker-ignored | dotenv or the framework's loader |
| CI | The CI platform's secret store, masked in logs | Injected as env for the job |
| Production, small setup | Encrypted file in git (Ansible Vault, SOPS) | Decrypted during deploy |
| Production, many services | Vault, AWS or GCP Secret Manager, Doppler | Fetched at runtime with per-service access |
Whatever the stage, make the app fail at startup when a required variable is missing, instead of booting half-configured and failing on the first request that needs it:
const required = ['DATABASE_URL', 'JWT_SECRET']
const missing = required.filter((key) => !process.env[key])
if (missing.length) throw new Error(`Missing env vars: ${missing.join(', ')}`)
Print the names of missing variables, never their values, and check that your error tracker scrubs environment data before you find out the hard way.
FAQ
Can I use a .env file in production?
You can, as long as the file is created on the server at deploy time from an encrypted source and never lives in the repo or the image. What you should avoid is a plaintext production .env that someone copies around by hand or bakes into a Docker layer.
Should I encrypt my .env file?
Encrypt the copy you store, not the one the app reads. Tools like SOPS or Ansible Vault keep an AES-encrypted file in git and decrypt it only during deployment, so a repo leak exposes ciphertext. The decryption key must live outside the repo.