There Is No .env Spec: The Same File Parses Differently in Every Runtime
I lost an afternoon to a password that worked locally and failed in the container. Same .env file, same credentials, same database. The application kept getting authentication errors, and the password in the logs looked exactly right — until I noticed the log had printed it with the quotation marks still attached. DB_PASSWORD="s3cret" had become the seven-character string "s3cret", quotes and all, because docker run --env-file takes values as-is and never strips them. Docker Compose's env_file, sitting in the same repository and reading the same file, strips them properly.
That is not a Docker bug. It is the consequence of a fact that surprises people who have used .env files for a decade: there is no .env specification. No RFC, no grammar, no reference implementation. Every runtime that reads one — python-dotenv, the dotenv npm package, Node's built-in --env-file, Ruby's dotenv, Go's godotenv, Docker Compose, your shell — implemented its own parser from the same rough convention, and they disagree in exactly the places that hurt.
Where the parsers actually diverge
The disagreements are not exotic. They are quoting, escapes, interpolation and multiline values — which is to say, everything except the trivial KEY=value case.
| Behaviour | Typically supported | Frequently not |
|---|---|---|
| Stripping surrounding quotes | Compose env_file, python-dotenv, npm dotenv |
docker run --env-file (quotes become part of the value) |
\n expansion inside double quotes |
python-dotenv, npm dotenv |
shells, docker run --env-file |
| Real newlines inside a quoted value | Node, Python, Ruby, godotenv (since v1.5.0, February 2023) | Docker Compose env_file |
${OTHER_VAR} interpolation |
python-dotenv, Compose's own .env, shells |
many library parsers, unless enabled |
export FOO=bar prefix |
most libraries, shells | not universal |
Inline # comment after a value |
usually stripped | sometimes part of the value |
Two of those rows deserve naming individually, because they are the ones that have actually cost me time.
Node's built-in --env-file is not the dotenv package. Node gained --env-file in v20.6.0, and a lot of teams dropped the dependency on the assumption it was the same parser with a shorter import. It is a separate implementation with its own edge cases — inner quotes in particular are handled differently, and a value can terminate at a quote where the library would have kept reading. If you switch, re-check every value in the file that contains a quote character, not just the ones that look complicated.
Docker Compose changed its own behaviour. Around v1.28 Compose moved to a dotenv-style parser, which is when quote stripping and escape handling arrived. A .env file written against the older behaviour, where a value was taken more literally, means something different today. If you have a long-lived Compose file, this is worth an explicit check rather than an assumption.
The practical rule I settled on is dull and works: quote nothing unless the value contains a space, and if it contains anything more exotic than a space, do not put it in a .env file at all.
Multiline values are the hard case, and Base64 is the answer
The value people most want to put in an environment variable and least can is a PEM private key. It is multiline by definition, it contains -----BEGIN markers, and every parser in the table above handles it differently — Docker Compose's env_file will not take a real-newline multiline value at all.
Every workaround built on escaping is a workaround built on the parser you happen to be using today. The portable answer is to stop transporting newlines:
# Encode once, at the point the key is generated.
base64 -w0 private.pem # GNU coreutils; use `base64 | tr -d '\n'` on macOS
$pem = base64_decode($_ENV['APP_PRIVATE_KEY'], strict: true);
One line, no newlines, no quotes, no escapes, identical in every runtime. The size cost is the usual 33% overhead that comes with how Base64 encoding works, which for a key is irrelevant, and you can check a value round-trips with a Base64 encoder before committing to it. If the key in question is one you are generating yourself, the storage and rotation decisions around a generated RSA key pair matter far more than how you get it into the process.
Empty is not unset, and everything is a string
Two type facts that cause more production incidents than the parsing does.
The environment holds strings only. DEBUG=false is the five-character string false, which is truthy in most languages. Every framework layers its own coercion on top — Symfony has %env(bool:DEBUG)% and friends — and those casts are the framework's rules, not the environment's. Reading getenv('DEBUG') directly and treating the result as a boolean is a bug that ships quietly and works fine until the first time somebody sets it to false.
FOO= and no FOO at all are different states. The first sets an empty string; the second leaves the variable unset. Code written as getenv('FOO') ?: $default collapses both to the default, which is usually what you want and occasionally catastrophic — an empty FOO= in a .env intended to disable something silently re-enables the default instead. If the distinction matters, check for existence separately from emptiness.
The file does not exist at runtime, and that is the whole point
A .env file is a development convenience for populating the process environment. The environment is the real interface, and in production something else usually populates it — an orchestrator, a systemd unit, a secret manager sidecar, a CI variable group. Once you internalise that, a whole class of "it works on my machine" bugs becomes obvious rather than mysterious.
The clearest example is cron. A cron job runs with a deliberately minimal environment — a short PATH, no shell profile, and certainly no .env loaded — which is why a command that works perfectly in your interactive shell does nothing at 3am. That is the single most common entry in the checklist for cron jobs that never fire or run wrong, and the fix is always to make the job's environment explicit rather than to hope something inherits.
The same reasoning applies to anything that spawns a process outside your application's own boot sequence: queue workers started by supervisor, scripts invoked from a deploy hook, a shell you opened with docker exec. The variables are there because someone put them there, and if nobody did, they are not.
Exposed .env files are scanned for within hours
This is not a hypothetical risk and the numbers are worth stating plainly. Automated scanners request /.env, /.env.local, /.env.backup and /.env.production against new deployments continuously — a survey by Mysterium VPN found 12 million IP addresses exposing .env files to the public internet. The 2024 Unit 42 research documented a large-scale cloud extortion campaign built entirely on harvesting them, and the DFIR Report's April 2026 write-up of the Bissa Scanner operation found over 30,000 distinct .env filenames collected in a single twelve-day window, spanning AI provider keys, cloud credentials, payment platforms and databases. One assessment put the share of AI-assisted applications shipping an exposed .env at 78%.
The web-root exposure is the loudest failure but not the only one. A .env copied in during a Docker build stays in that image layer even if a later layer deletes it. Values passed as ENV or --env are readable through docker inspect by anyone with daemon access. On Linux a process's environment is readable at /proc/<pid>/environ. And plenty of error handlers dump the full environment into a stack trace, which then goes to a third-party error tracker.
Concretely, the four checks worth running today: .env is in .gitignore and absent from git log --all -- .env; the web server returns 404 for /.env rather than the file; your error handler redacts environment values rather than printing them; and the file never enters an image layer.
Secrets belong in a secret manager, .env belongs in development
My position on this is not subtle. A .env file is an excellent way to configure a local development environment and a poor way to hold production secrets, because it has no access control, no audit trail, no rotation story, and no way to answer "who read this and when". Every serious platform now offers something better, and the migration is usually smaller than it looks — the application still reads the process environment either way, so what changes is who populates it.
What genuinely belongs in a committed .env file: non-secret defaults, feature flags, service URLs for local containers, and a .env.example documenting every variable the application needs with placeholder values. That example file is the actually valuable artefact — it is the only documentation most projects have of their configuration surface, and a missing variable discovered at boot is far cheaper than one discovered by a null pointer at 2am.
For the placeholders and for any local-only secret you do need to generate, produce it properly rather than typing something memorable: a random value from a password generator costs nothing and removes the temptation to reuse a real credential in a development file that will end up on someone's laptop.
Name the Parser Before You Quote
Before you add a value with a quote, a space, a $, or a newline in it, answer one question: which parser reads this file, in every context it runs — the local shell, the test runner, Compose, the deploy pipeline? If the answer is more than one, the value has to survive all of them, and the only values that reliably do are unquoted single-line strings.
Everything else — keys, certificates, anything with structure — gets Base64-encoded on the way in. It is the one representation none of these parsers can misread.