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.

More Articles

WebMCP Lets a Page Hand Agents Its Tools — If the Page Holds a Trial Token

WebMCP is in a Chrome origin trial until 16 November 2026. The document.modelContext API, declarative forms, what broke for early adopters, and the risks.

11 October, 2026

The Lockfile Hash Checked Out. The Package Was Still Malware.

What integrity: sha512 in package-lock.json verifies, why axios, TanStack and ChainDrop all passed it, and the npm settings that actually help.

7 October, 2026

The Agent Skills That Pay Off Are Scripts With a Manual Attached

Curated skills add 16.6pp, agent-written ones add nothing, and half never trigger. What makes a SKILL.md pay off, and how to vet third-party skills.

3 October, 2026