Vivian Voss

The .env File Nobody Needs

unix freebsd linux security

The Unix Way ■ Episode 10

107 million weekly downloads.

That is the current figure for dotenv on npm. A package whose entire purpose is reading KEY=VALUE pairs from a file and placing them into process.env. Thirteen lines of meaningful code. One regular expression. No cryptography. No validation. No access control. It reads a file. It sets variables. It has 107 million weekly consumers.

Unix has done this since 1979.

The Archaeology

Version 7 Unix introduced environment variables forty-seven years ago. The mechanism is rather elegant: a parent process passes key-value pairs to its children through exec(). Every process inherits. No library. No file to parse. No package to install. The operating system provides the transport. The process receives the values. The contract is simple, universal, and has worked without modification since Jimmy Carter was president.

Then somewhere along the way, the ecosystem decided this was insufficient. The solution: write your secrets into a file called .env, place it in your repository root, and install a package to read it. One does rather admire the confidence required to look at a forty-seven-year-old mechanism with zero CVEs and think "I can improve this with a text file and an npm install."

The Cost

GitHub detected 39 million leaked secrets in 2024. A 67 per cent increase from the previous year. Toyota exposed 270,000 customer records through a single access key committed to a public repository. Not a sophisticated attack. Not a zero-day exploit. Someone forgot to add a line to .gitignore.

The .env file is not a security mechanism. It is a plaintext file containing your database credentials, your API keys, and your payment provider secrets, sitting one absent .gitignore entry away from publication. The file has no permissions model. No encryption. No audit trail. It is a sticky note on a monitor, formatted as UTF-8.

Where Your Secrets Live .env file (dotenv) Plaintext in repository root One .gitignore miss = published No permissions model No encryption No audit trail OS Environment Variables Inherited by process, not stored in repo Cannot be accidentally committed File-system permissions on config Process isolation (per-service) 47 years. Zero CVEs on mechanism GitHub: 39 million leaked secrets in 2024. +67% year over year. Toyota: 270,000 customer records exposed via one committed key.

FreeBSD

FreeBSD solves this at the operating system level, where it belongs.

login.conf(5) sets environment variables per login class. A developer gets one set of variables. A service account gets another. No file in your repository. No dependency. The operating system handles it before your application starts:

# login.conf: per-class environment
default:\
  :setenv=LANG=en_GB.UTF-8,EDITOR=vi:\
  :path=/sbin /bin /usr/sbin /usr/bin:

For services, rc.d sources variables from rc.conf:

myapp_env="DATABASE_URL=postgres://db/prod"

The configuration lives in /etc, protected by file-system permissions, managed by the system administrator, and entirely invisible to your version control. The application reads process.env.DATABASE_URL and has no idea where it came from. Nor should it.

Linux

/etc/environment provides system-wide variables without requiring a shell:

DATABASE_URL=postgres://localhost/prod

systemd's EnvironmentFile does precisely what dotenv does. Since 2010. No npm install. The file lives in /etc, where configuration belongs. Not in your Git repository, where it terribly does not:

[Service]
EnvironmentFile=/etc/myapp/env
Environment=NODE_ENV=production

The service reads the file at startup. The variables are injected into the process environment. The application code is identical. The secrets never touch your repository. The mechanism has existed for sixteen years. It requires zero packages.

Where Configuration Belongs FreeBSD login.conf(5) rc.conf(5) per-class, per-service Linux /etc/environment EnvironmentFile= system-wide, per-service dotenv .env in repo root 107M weekly downloads plaintext, no isolation /etc — managed by OS, protected by permissions ./ — in your Git repo Your application should not know where its configuration comes from. The OS sets the environment. The process inherits it. That is the contract. Forty-seven years. Zero CVEs on the mechanism itself.

Vault Integration

The inevitable objection: "But we use HashiCorp Vault. We use AWS Secrets Manager. We use Azure Key Vault. Surely the application needs a client library to fetch secrets at runtime?"

It does not. The pattern is identical to everything above, with one additional step: the init system fetches the secret before the process starts, writes it to a transient file, and the process inherits it as an environment variable. The application has no Vault dependency. No SDK. No client library. No retry logic. No token renewal. It reads process.env.DATABASE_URL and carries on with its life, blissfully unaware that a rather expensive piece of enterprise infrastructure was involved.

HashiCorp Vault with systemd:

[Service]
ExecStartPre=/bin/sh -c 'vault kv get -field=dsn secret/myapp > /run/myapp/env'
EnvironmentFile=/run/myapp/env
ExecStopPost=/bin/rm -f /run/myapp/env

ExecStartPre runs before the service starts. It calls Vault, extracts the secret, and writes it to /run/myapp/env. That path lives on tmpfs, which means it exists only in memory and vanishes when the service stops or the machine reboots. The secret never touches persistent storage. EnvironmentFile reads it. The application starts. ExecStopPost cleans up. Three lines.

AWS Secrets Manager, same pattern:

[Service]
ExecStartPre=/bin/sh -c 'aws secretsmanager get-secret-value \
  --secret-id myapp/prod --query SecretString --output text > /run/myapp/env'
EnvironmentFile=/run/myapp/env

Different vault. Different CLI. Identical architecture. The init system handles the plumbing. The application inherits the result. One suspects the Vault SDK authors would prefer you did not know this.

FreeBSD with rc.d and Vault:

myapp_precmd()
{
    export DATABASE_URL=$(/usr/local/bin/vault kv get -field=dsn secret/myapp)
}

run_rc_command "$1"

The precmd function runs before the service starts. It calls Vault, sets the variable via export, and the process inherits it. FreeBSD Jails provide an additional isolation boundary: the Vault token can be scoped to the jail, and the secret never leaves the jail's process tree. No SDK. No library. No runtime dependency. A shell function and an export.

The Pattern: Init Fetches, Process Inherits Vault any provider Init System ExecStartPre= or rc.d precmd /run (tmpfs) memory only App process.env HashiCorp AWS SM Azure KV systemd, rc.d fetches before start vanishes on stop never on disk no SDK no client lib The pattern is thirty years old. The vault products are new. The architecture between them is a one-liner.

The pattern is thirty years old. The vault products are new. The architecture between them is a one-liner. ExecStartPre fetches. EnvironmentFile delivers. The application inherits. Three moving parts, all provided by the operating system, all battle-tested, all free. One does wonder what the Vault SDK's 47 transitive dependencies are doing that a shell script and tmpfs are not.

The Concession

Even Node.js conceded. Version 20.6.0 added --env-file as a built-in flag. No package required. The runtime spent a decade outsourcing a one-liner to a third-party dependency, then quietly shipped it itself. One imagines the commit message was written with a certain reluctance.

node --env-file=.env app.js

But the actual Unix answer is simpler still: your application should not know where its configuration comes from. The OS sets the environment. The process inherits it. That is the contract. It has worked since 1979. It works now. It will work when the npm registry is a historical curiosity and node_modules is studied by digital archaeologists as evidence of a civilisation that optimised for convenience over correctness.

If You Must

If you must use .env files, and sometimes local development genuinely benefits from them, then at minimum:

  • Never commit them. Ever. Not once. Not "just for testing." Not "it's only the staging key." Ever.
  • .gitignore is not optional. It is the first line of defence and the only one you have.
  • Rotate credentials the moment they touch Git history. git rm does not remove them from history. They are there forever. Act accordingly.
  • Consider: if it needs a file, it needs /etc, not ./

107 million weekly downloads. For reading lines from a file.

Rather marvellous, that.

Unix has had environment variables since 1979. FreeBSD sets them per login class and per service. Linux sets them via /etc/environment and systemd EnvironmentFile. Node.js shipped --env-file natively in v20.6.0. dotenv has 107 million weekly downloads. GitHub detected 39 million leaked secrets in 2024. The .env file is a plaintext sticky note one .gitignore miss from publication.