Short answer: keep the canonical values in an encrypted vault, and let .env be a temporary file you generate when tooling needs one. If you do keep a persistent file, the best location is outside the repository tree, with 600 permissions, on a Mac with FileVault on.
Your runtime still reads config from the environment — 12-factor is right about that. The question here is narrower and more practical: which path on disk, and what makes one path safer than another.
Ranked, with the actual paths
| Rank | Location | Verdict |
|---|---|---|
| 1 | Encrypted vault, file generated on demand | Best. No long-lived plaintext exists |
| 2 | ~/.config/acme/api.env, mode 600 | Good. Survives "zip the project" |
| 3 | ./env in the repo, gitignored and verified | Acceptable for test-mode keys |
| 4 | ~/Documents/… or ~/Desktop/… | Bad. iCloud syncs these by default |
| 5 | Dropbox / Google Drive / OneDrive | Bad. Uploaded, versioned, shared |
| 6 | /tmp/env | Bad. World-readable directory, unpredictable lifetime |
| 7 | Committed to Git, even privately | Never |
The jump that matters most is 3 → 2. Moving the file out of the repository directory eliminates a whole family of accidents — git add ., zipping the project for a contractor, an IDE's "export project", a folder dragged into a chat window — because the secret is no longer inside the thing you copy and share.
Why the repo folder feels safe and isn't
Your project lives in ~/Projects/acme/api/, and you create .env next to package.json because every tutorial does. It's never been public. But over eighteen months, that directory gets:
- Zipped and sent to a contractor
- Backed up by Time Machine, snapshot after snapshot
- Copied to a new laptop during a migration
- Opened during screen shares,
cat .envand all git add .-ed by someone in a hurry at 1am
None of those is a security failure in the dramatic sense. They're ordinary things people do with project folders, and the secret is inside the folder. How these play out in practice: why your .env setup is probably leaking.
Option 1 — Vault as the source of truth, file generated on demand
The strongest pattern, because the risky artifact doesn't persist.
Keep real values in an encrypted store. On macOS, PassStore holds them in an AES-256-GCM vault with Argon2id key derivation, unlocked by Touch ID and re-locked on a timer, with no cloud sync (Security).
Then either inject directly, with no file at all:
# Scoped to exactly one child process
STRIPE_SECRET_KEY="$(security find-generic-password -s acme_stripe_test -w)" \
npm run dev
Or generate a file, use it, and delete it:
# Makefile
dev:
@printf 'STRIPE_SECRET_KEY=%s\n' "$$(security find-generic-password -s acme_stripe_test -w)" > .env.local
@trap 'rm -f .env.local' EXIT; npm run dev
The trap matters: it removes the file even if the dev server exits with an error or you Ctrl-C it. Without it, you've just created a persistent plaintext file with extra steps.
Name generated files distinctly — .env.local, .env.generated — so it's obvious at a glance that they're disposable rather than the source of truth.
Option 2 — Outside the repository, locked down
If you want one persistent file, put it where a project archive won't include it. Follow the XDG convention:
mkdir -p ~/.config/acme
chmod 700 ~/.config/acme # only you can even list it
printf 'STRIPE_SECRET_KEY=sk_test_...\n' > ~/.config/acme/api.env
chmod 600 ~/.config/acme/api.env # owner read/write only
ls -l ~/.config/acme/api.env
# -rw------- 1 you staff ... api.env
Then point your tooling at it explicitly:
# Node, with dotenv
node -r dotenv/config app.js dotenv_config_path=$HOME/.config/acme/api.env
# Docker Compose
docker compose --env-file ~/.config/acme/api.env up
# A shell wrapper
set -a; . "$HOME/.config/acme/api.env"; set +a
Be clear about what 600 does. It stops other user accounts on the machine from reading the file, which matters on shared or multi-user systems. It does not stop processes running as you — that includes every npm script and every dependency's postinstall hook. It's worthwhile hygiene, not a security boundary.
Also worth knowing: Git does not track file permissions beyond the executable bit. A 600 file committed and cloned elsewhere comes back with default permissions. One more reason these files don't belong in Git.
Avoid symlinking the file back into your project. A symlink puts the path back inside the tree, and archive tools differ on whether they follow it — some will happily dereference and include the secret in your "clean" zip.
Option 3 — In the project, gitignored and verified
Common, and acceptable for test-mode credentials with low blast radius. The requirements are all mandatory, not aspirational:
# Secrets — never commit
.env
.env.*
!.env.example
!.env.sample
!.env.template
# Local overrides and stray credential files
*.local.env
credentials.json
secrets.yml
service-account*.json
Then verify, because assumption is how this fails:
# Confirm the ignore rule matches, and see which rule did it
git check-ignore -v .env || echo "WARNING: .env is NOT ignored"
# Catch a file already tracked despite .gitignore — the most common trap.
# .gitignore has no effect on files Git already tracks.
git ls-files --error-unmatch .env 2>/dev/null && echo "WARNING: .env is TRACKED"
# Confirm the project isn't sitting inside a synced folder
pwd | rg -q '/(Desktop|Documents|Dropbox|OneDrive|Google Drive)/' \
&& echo "WARNING: project is in a cloud-synced folder"
That second check is the one people miss. Adding a path to .gitignore does nothing if Git is already tracking it — you need git rm --cached as well.
Pair it with a committed template so the variable schema is documented without any value being present:
# .env.example — safe to commit
DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_replace_me
SENTRY_DSN=
Note the negation lines in the .gitignore above. Without !.env.example, the .env.* pattern excludes your template too, and the contract never gets committed.
Option 4 — A team secret manager
For shared or non-local values, the answer isn't a file location at all — it's replacing ad-hoc file sharing with a system that has access control and revocation: Doppler, Infisical, Vault, or a cloud vendor store.
This is the correct answer whenever more than one person needs the same value. See local-first vs cloud secret managers and how to share env files safely with your team.
Where not to put it
~/Documents or ~/Desktop. If "Desktop & Documents Folders" syncing is on — and it is for many people without their knowing — everything there is uploaded to iCloud. Check:
# If this directory exists, your Desktop and Documents are syncing
ls ~/Library/Mobile\ Documents/com~apple~CloudDocs/ >/dev/null 2>&1 && echo "iCloud Drive active"
Keep code in ~/Developer or ~/code instead. Neither syncs by default.
Dropbox, Google Drive, OneDrive. Uploaded, version-retained, and often shared at the folder level with people you've forgotten about. Retained versions mean deleting the file doesn't remove the secret.
/tmp. World-readable on macOS, and cleanup timing isn't something to rely on. If you need a temporary file, create it with mktemp in a directory you control and trap its removal.
Your home directory root. ~/.env gets picked up by tools run from ~, and it's easy to forget it exists — a stale credential nobody rotates.
Shared network drives or NAS. Broad read access, and usually backed up somewhere with even broader access.
Docker and Compose
Keep the Compose file in Git; keep the values out:
services:
api:
env_file:
- .env # gitignored, or pass --env-file with a path outside the repo
environment:
- NODE_ENV=development # non-secret config inline is fine
Two Docker-specific traps:
Never pass secrets as build args. ARG and ENV values are baked into image layers and persist — including in public registries:
docker history --no-trunc myimage:latest | rg -i 'ARG|ENV'
Use BuildKit secret mounts (--mount=type=secret) if something is genuinely needed at build time, or restructure so it isn't.
Compose parses quotes differently from most language dotenv libraries, so a value that works locally can arrive inside the container with literal quote characters. Verify inside the container rather than trusting the file:
docker compose run --rm api sh -c 'echo "${#STRIPE_SECRET_KEY}"'
For production, use orchestrator secrets — Kubernetes Secret objects, ECS task definition secrets — not a file in the image.
Monorepos
Two workable layouts:
# Per-package — preferred
apps/api/.env
apps/web/.env
packages/shared/.env.example
# Single root file — simpler, wider blast radius
.env
Per-package scales better. Each service sees only the variables it needs, ownership is obvious, and a leak is contained to one package. The cost is some duplication for genuinely shared values — accept it, because the alternative is every service holding every credential.
Whichever you choose, make it consistent and document it in the README. The failure mode is a mix: a root .env, three package-level files, and nobody knowing which one wins. Deeper: how to structure environment variables in large projects.
macOS hardening that applies regardless of location
# Full-disk encryption — should print "FileVault is On."
fdesetup status
# Screen lock delay; 0 = immediately
defaults read com.apple.screensaver askForPasswordDelay
- FileVault on. Without it, a stolen powered-off Mac is a stolen
.envfile. - Screen lock immediately, and learn
Control-Command-Q. - Remember Time Machine keeps deleted files. A
.envyou removed months ago may still be restorable — which is why revocation, not deletion, is what ends an exposure. - Prefer Keychain-backed storage for long-lived tokens: macOS Keychain for developers.
Should .env be in .gitignore or .git/info/exclude?
Use .gitignore, committed, for anything the whole team should ignore — .env, .env.*. It's shared, reviewable, and new clones inherit it.
Use .git/info/exclude only for personal, machine-specific files you don't want to impose on others, like a scratch directory. It isn't committed, so it protects only you — which makes it the wrong tool for secrets, since your teammates need the same protection.
A useful backstop is a global ignore file, so a fresh repo isn't unprotected while you're setting it up:
git config --global core.excludesfile ~/.gitignore_global
printf '.env\n.env.*\n!.env.example\n' >> ~/.gitignore_global