The Unix Way ■ Episode 10
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.
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.
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 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.
.gitignoreis not optional. It is the first line of defence and the only one you have.- Rotate credentials the moment they touch Git history.
git rmdoes 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.