Guide

Environment variable manager for macOS developers

How to manage dev, staging, and production-style variables on a Mac without syncing secrets to the cloud: patterns, anti-patterns, tooling comparison, and using PassStore as your local source of truth.

Environment variables are the default configuration API in almost every language — Node, Python, Go, Rust, Ruby, PHP. 12-factor got this right: config belongs in the environment, not in code.

What 12-factor never said is that the storage for those values should be a plaintext file next to your source code. That part was a convention that emerged because it was convenient, and it's the origin of most secret leaks developers actually experience.

This guide covers what a serious environment-variable workflow looks like on macOS: the separation between storage and transport, multi-environment layout, the framework gotchas that leak keys into browsers, and where a local vault fits. It stands alone — you don't need PassStore to use any of it.


1. The distinction that fixes most problems

Three separate concerns get collapsed into "the .env file", and separating them resolves most confusion:

LayerWhat it doesGood options
StorageWhere the value rests between runsEncrypted vault, cloud secret manager, CI secret store
TransportHow it gets into a process.env file, shell injection, platform config
RuntimeHow code reads itprocess.env, os.environ — unchanged

The runtime layer is fine. Keep reading config from the environment. The mistake is using the transport format as the storage layer: leaving .env on disk for months, treating it as the canonical copy, copying it between machines.

Once separated, the fix is obvious: keep canonical values somewhere encrypted, materialise a .env only when tooling demands a file, and delete it after.


2. What an "env manager" should actually do

  1. Group variables per application and environment, so DATABASE_URL for staging is structurally distinct from production.
  2. Keep secrets out of Git while keeping variable names in Git, so the schema is documented.
  3. Inject values deliberately — into a shell, a container, or an IDE — rather than via screenshots and Slack.
  4. Support rotation without archaeology across twelve env.backup.final2 files.
  5. Distinguish secrets from config. PORT and LOG_LEVEL are not secrets, and treating everything as secret creates noise that devalues the real controls.

That last point matters more than it sounds. If your .env has thirty lines and four are secrets, the twenty-six harmless ones train everyone to treat the file casually.


3. Anti-patterns that cause incidents

Anti-patternWhy it hurts
One giant .env for everythingThe wrong DATABASE_URL in the wrong terminal tab
Committing .env to a "private" repoHistory is forever; visibility changes; forks keep copies
Emailing or Slacking .env to onboard someoneIndefinite retention, searchable, in export archives
Production secrets on every laptopBlast radius multiplied by headcount
set -a; source .env; set +a in your shell profileEvery process inherits prod credentials for the session
Prefixing a secret with NEXT_PUBLIC_ or VITE_Published to every browser that loads your site
.env inside iCloud-synced Desktop or DocumentsSilently uploaded
Keeping .env.old "just in case"Stale credentials nobody rotates

Deeper: why your .env setup is probably leaking.


4. Baseline layout

Committed template

.env.example, with names and obviously-fake values:

APP_ENV=development
PORT=3000
DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/myapp_dev
REDIS_URL=redis://127.0.0.1:6379
STRIPE_SECRET_KEY=sk_test_replace_me
SENTRY_DSN=

This file is the contract. It documents which variables exist so a new developer — or you, in six months — knows what's required without a secret ever being committed.

Gitignored local values

cat >> .gitignore <<'EOF'
.env
.env.*
!.env.example
EOF

# Verify rather than assume
git check-ignore -v .env

# Catch a file that's already tracked despite .gitignore — very common
git ls-files --error-unmatch .env 2>/dev/null && echo "TRACKED — fix this"

The negation pattern (!.env.example) matters: without it, .env.* excludes your template too, and the contract never gets committed.

CI and production use platform injection

Not a file in the repo. GitHub Actions secrets, Kubernetes Secret objects, ECS task definitions, platform env config:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

Better still, where supported: OIDC, so there's no stored credential at all.


5. Multiple environments without duplicating secrets

The goal is separation with a single source of truth per value.

.env.example          # committed — names only, the contract
.env                  # gitignored — shared local defaults, non-secret
.env.local            # gitignored — your personal overrides
.env.development      # gitignored — env-specific
.env.production       # should not exist on a laptop

Layer by specificity, not by duplication. Put non-secret shared defaults in .env, and only the values that genuinely differ in the more specific file. Copying all thirty variables into each environment file is how they drift.

.env.production on a developer machine is a smell. If production values are needed locally, that's a break-glass process — documented, audited, temporary — not a file.

Framework-specific behaviour matters here, because load order determines which value actually wins. Next.js, for example, resolves in this order, stopping at the first match (Next.js docs):

  1. process.env
  2. .env.$(NODE_ENV).local
  3. .env.localnot checked when NODE_ENV is test
  4. .env.$(NODE_ENV)
  5. .env

The test exception surprises people: your .env.local overrides silently don't apply in tests, so a test suite can hit a different database than your dev server. Check your own framework's documented order rather than assuming it matches.

More: how to manage multiple .env files across dev, staging, and prod · how to avoid duplicate env configs.


6. .env is not a standard

There's no specification. Every loader parses differently, and the differences cause real incidents.

Where implementations diverge:

  • Quoting. Whether " and ' are stripped, and whether they behave differently.
  • Multiline values. A PEM private key or a JSON blob may need quoting, escaped \n, or may be unsupported.
  • Interpolation. Some loaders expand ${OTHER_VAR}; others treat it literally. A $ in a password can silently mangle a value.
  • Comments. Whether # mid-line starts a comment or is part of the value.
  • Whitespace. Whether trailing spaces are stripped.
  • Docker's --env-file notably does not handle quotes the way most language libraries do. A value that works locally can arrive inside a container with literal " characters.

The practical consequence: a key that looks correct fails authentication, someone debugs the wrong thing for an hour, then pastes the value somewhere visible to compare it by eye — and now there's an extra copy. How to copy environment variables without mistakes.

A quick integrity check before blaming the vendor:

# Reveal trailing whitespace and CR characters (a classic after copy/paste)
cat -A .env | rg 'STRIPE|DATABASE'

# Compare a loaded value's length against what you expect
node -e 'require("dotenv").config(); console.log(process.env.STRIPE_SECRET_KEY?.length)'

7. The build-time trap

The highest-severity environment variable mistake isn't a leaked file — it's a published bundle.

Frontend build tools deliberately inline prefixed variables into JavaScript sent to browsers: NEXT_PUBLIC_* in Next.js, VITE_* in Vite, REACT_APP_* in Create React App. This is a feature, and it's why the prefix exists — but it means anything with that prefix is public, permanently, on a CDN.

# Verify what actually shipped
rg -o 'sk_live_[A-Za-z0-9]+' .next/static && echo "LIVE KEY IN THE BUNDLE"

The rule: if a value needs to be secret, it must never carry a public prefix, and it must only be read in server code. When the browser genuinely needs to call a protected service, proxy through your own backend (a route handler, a server action, a BFF) so the secret stays server-side.

Full treatment: how to prevent leaking API keys in frontend apps.


8. Shell session bleed

This is the "wrong terminal" incident, and it's more common than any tooling failure.

# Exports into your shell — every process launched from this terminal inherits it,
# for the rest of the session, including scripts you never audited.
set -a; source .env; set +a

You load production values to debug something, forget, and three hours later a local test run talks to production. Scope variables to a single process instead:

# Inherited by exactly one child process, then gone
STRIPE_SECRET_KEY="$(secret STRIPE_SECRET_KEY)" npm run dev

Never put secrets in .zshrc or .bash_profile. They then apply to every terminal you open, forever, and they're plaintext in a file most people sync between machines or commit to a dotfiles repo.


9. Docker and Compose

Keep the Compose file in Git; keep the values out:

services:
  api:
    env_file:
      - .env          # gitignored
    environment:
      - NODE_ENV=development   # non-secret config inline is fine

Two Docker-specific traps:

Build args are baked into layers. ARG and ENV values persist in the image, including in public registries:

docker history --no-trunc myimage:latest | rg -i 'ARG|ENV'

Never pass secrets as build args. Use BuildKit secret mounts (--mount=type=secret) if a secret is genuinely needed at build time, or restructure so it isn't.

Quote handling differs, as noted above. Test the value inside the container rather than trusting the file:

docker compose run --rm api sh -c 'echo "${#STRIPE_SECRET_KEY}"'

10. direnv and monorepos

direnv auto-loads .envrc when you cd into a directory. Genuinely useful, and easy to misuse:

  • Never commit real secrets in .envrc — it's a shell script, so people treat it as code and review it less carefully than they'd review a config file.
  • Prefer having .envrc fetch from your vault rather than contain values:
    # .envrc — safe to commit: references, not secrets
    export STRIPE_SECRET_KEY="$(security find-generic-password -s acme_stripe_test -w)"
    
  • Remember direnv allow is a trust decision. A malicious .envrc in a cloned repo runs arbitrary code.

Monorepos need a deliberate choice: one env file at the root, or one per package. Per-package scales better because a service only sees the variables it needs, which limits blast radius and makes ownership obvious — at the cost of some duplication for genuinely shared values. Deeper: how to structure environment variables in large projects.


11. How PassStore fits

PassStore is a native macOS vault for developer secrets, usable as the storage layer beneath whatever transport your tooling needs.

  • AES-256-GCM vault encryption, Argon2id key derivation (Security)
  • Touch ID / Secure Enclave unlock; auto-lock so the readable window is minutes, not your whole workday
  • No cloud sync, no account server — nothing to sync means nothing to leak in transit
  • .env groups as a first-class type, alongside API keys, database URLs, and SSH credentials
  • Workspace per repository, so vault structure mirrors your Git remotes
  • Vault health audit for reused and weak values
  • Clipboard auto-clear; command palette and menu bar access
  • Encrypted backup export and import
  • Native Swift/SwiftUI, macOS 26.0+, free and MIT: github.com/ilmakio/PassStore

Typical workflow

  1. One workspace per repo or product.
  2. Groups for dev and staging (production stays in your cloud manager).
  3. Store DATABASE_URL, signing keys, and third-party tokens as named entries, one per credential — not one entry holding a pasted .env blob, because rotation targets individual values.
  4. Generate a .env when tooling requires a file, and delete it on exit:
    # 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
    

Download for macOS


Should I commit .env.example?

Yes, always. It's the only part of your environment configuration that belongs in Git.

It documents which variables exist, lets CI validate that nothing is missing, and makes onboarding a cp .env.example .env away. Use obviously-fake placeholder values — sk_test_replace_me, not a real test key, because real test keys get promoted to real usage by accident.

A useful CI check that the contract stays honest:

# Fail if .env.example gained a variable that nobody documented downstream
diff <(rg -o '^[A-Z_]+' .env.example | sort) <(rg -o '^[A-Z_]+' .env | sort)

Do I need a tool, or is a .env file enough?

For non-secret config, a file is enough and a tool is overhead.

You need something better when any of these are true: the file contains credentials with real blast radius; you work across more than a handful of projects and have lost track of what you have; you've had a near-miss with git add .; you need to rotate and can't tell where a value lives; or the same credential exists in multiple places and you don't know which is current.

The honest threshold is roughly five projects or the first near-miss, whichever comes first. Below that, careful .gitignore discipline plus a pre-commit scanner genuinely is fine.

See is it safe to store secrets in .env files? for the per-secret-type breakdown.


PassStore app iconDownload PassStore — local macOS vault for developer secrets.