🍱 Lunchbox Hands

env

There Is No .env Spec: Why Your Secret Works in Dev and Breaks in Docker

A password containing # silently truncates in dotenv, executes in bash, and survives in Docker Compose. Four parsers compared on the same fourteen lines, with real output — quoting, inline comments, multiline values, interpolation, and the duplicate-key rule each one picked.

Your database password is hunter2#secure. In local development the app connects. In Docker it connects. In CI it fails authentication, and the logs show the app trying to connect with the password hunter2. Nothing in your code touched that string — but the parser reading your .env file did. There is no .env specification. Every tool that reads one implements its own dialect, and the dialects disagree in exactly the places real secrets live: quotes, hashes, newlines, and dollar signs.

INI files at least have competing conventions to argue about. .env has less than that: a de-facto shape (KEY=value) and a dozen independent implementations that each answered the edge cases on their own. Here is what they actually do.

The same fourteen lines, four parsers

Everything in the table below is real output, not documentation paraphrase: dotenv 17.4.2 run under Node 22, bash 3.2.57 via set -a; source, Docker Compose behavior per its current documentation, and our own .env ⇄ JSON converter.

Input linedotenv 17.4.2source in bashDocker ComposeOur converter
A=1 # note1111
B=pa#sspapa#sspa#sspa#ss
C="line1\nline2"real newlineliteral \nreal newlinereal newline
D="line1line2"real newlineerror(single quotes only)real newline
export E=55555
F=firstF=secondsecondsecondsecondsecond
G= spaced spacederrorspacedspaced
H='keep #this'keep #thiskeep #thiskeep #thiskeep #this
J=${I}/xliteral ${I}/xexpands $Iexpands ${I}/xliteral ${I}/x
K="v" # trailingvvvv
N="say \"hi\""say \"hi\"say "hi"say "hi"say "hi"
M = 11error11
KEY:valuenot parsednot parsedparsednot parsed

Four columns, and no two are the same. The rows worth your attention:

Row B is the one that costs you an afternoon. Given B=pa#ss with no space before the #, dotenv 17.4.2 returns pa. Not an error, not a warning — a silently truncated secret. Docker Compose documents the opposite rule (“comments must be space-preceded” for unquoted values), and bash agrees with Compose. So the same unquoted password parses three different ways depending on which tool loads the file. Our converter keeps pa#ss, matching Compose and bash; that choice is deliberate and pinned by a test, because a truncated credential fails in a way that looks like a permissions problem rather than a parsing problem.

Row N is subtler. dotenv leaves the backslashes in say \"hi\"; bash, Compose, and our converter unescape to say "hi". If a JSON blob lives in your .env, the number of backslashes that survive depends on the loader.

Row J is the biggest architectural difference. dotenv does not interpolate at all — its docs point you to a separate package for that — so ${I} stays literal. Docker Compose does interpolate inside the .env file itself, with the full shell-style vocabulary: ${VAR:-default} for unset-or-empty, ${VAR-default} for unset only, ${VAR:?error} to fail hard, ${VAR:+alternate}. Single-quoted values in Compose are “used literally,” which is how you keep a literal ${…}. A file that is inert data to one loader is a small template language to the other.

Why source .env is the worst option

It is the reflex when you don’t want a dependency, and it has two failure modes that a real parser doesn’t.

The first is that ordinary values break it. GREETING=hello world, sourced in bash:

./t1.env: line 1: world: command not found

The assignment binds hello and then bash tries to run world. Unquoted spaces are fine in every dedicated .env parser and fatal here.

The second is worse. A sourced .env file is not data — it is a shell script:

$ cat t2.env
STAMP=$(id -un)
$ bash -c 'set -a; source ./t2.env; echo "STAMP=[$STAMP]"'
STAMP=[kylehurst]

Command substitution executed. Any .env file you source — from a teammate, a vendor’s onboarding doc, a container image, a copy-pasted gist — can run arbitrary commands as you, and the file extension gives no hint that it might. If you are going to load a .env file, use a parser that treats it as data.

Multiline values: the PEM key problem

Sooner or later something hands you a private key or a service-account JSON blob and expects it in an environment variable. Four workable approaches, in order of how much they’ll annoy you later:

  1. Base64 the whole thing. One long single-line value, no quoting questions, decodes identically everywhere. This is the boring right answer for keys and JSON blobs. The base64 encoder does it in the browser.
  2. Escaped newlines in a double-quoted value: KEY="-----BEGIN…\nMIIE…\n-----END…". Works in dotenv, Compose, and our converter — all three turn \n in double quotes into real newlines. Does not work in bash, which leaves \n as two characters.
  3. A real multiline quoted value. dotenv supports this with double quotes; Compose documents that “single-quoted values can span multiple lines.” Our converter now handles both quote styles across lines, keeping real newlines — that was a genuine gap until this post’s fact-checking found it silently producing a corrupted one-line value.
  4. Don’t put it in .env at all. Mount it as a file and pass the path. Secrets managers and container platforms all support this, and it dodges every rule in this post.

The rules that hold everywhere

Given four dialects, the safe subset is small and worth adopting as a house style:

  • Quote every value that isn’t [A-Za-z0-9_./-]+. Single quotes when you want the bytes exactly as written; double quotes when you want \n interpreted. This one habit neutralizes rows A, B, G, H, and N of the table.
  • Never put a bare # in an unquoted value. Quote it, or you’re relying on which parser loads the file.
  • Assume no interpolation. Compose interpolates, dotenv doesn’t; a file written for one and loaded by the other silently changes meaning. Write out the full value.
  • Last duplicate wins — but only within the file. All four parsers here take the last KEY= in the file. What differs is precedence against variables that already exist: dotenv’s documented default is that “if there is a variable in your .env file which collides with one that already exists in your environment, then that variable will be skipped” unless you pass override. So your .env loses to a real environment variable — which is usually what you want in production and confusing exactly once in development.
  • Keep .env out of git and out of your image. A .gitignore entry and a .dockerignore entry, both — they are different files with different matching rules, as dockerignore is not gitignore covers. Ship a committed .env.example with the keys and dummy values instead.

What our converter does, precisely

The .env ⇄ JSON converter runs entirely in the page — nothing you paste leaves the browser — and implements this dialect:

RuleBehavior
CommentsA line whose first non-whitespace character is # is skipped
Inline commentsStripped from unquoted values only when the # is at the start or preceded by whitespace; pa#ss is preserved
export prefixStripped
SeparatorFirst = on the line; the value may contain more =. Compose’s KEY:value form is not supported
Double quotes\n, \t, \r, \\, \" unescaped; a trailing comment after the closing quote is dropped
Single quotesLiteral, no unescaping
MultilineA quoted value whose closing quote is on a later line spans those lines with real newlines
InterpolationNone — ${VAR} is literal text
DuplicatesLast one wins
EmittingValues containing whitespace, #, =, a quote, or a control character are double-quoted and escaped; everything else is bare

The round trip is lossless in the direction that matters: JSON → .env → JSON returns what you started with, because the emitter quotes anything the parser would otherwise reinterpret. If you’re moving config between formats more broadly, INI to JSON round-trip gotchas is the same investigation for .ini — a format with even more dialects and the same absent spec — and the YAML ⇄ JSON converter covers the third corner of the config triangle.

The short version

The assumptionThe mechanism
.env is a standard format”No spec exists; dotenv, Compose, bash, and every library implement different dialects
”My password with a # is fine”dotenv 17.4.2 truncates pa#ss to pa with no warning; quote it
source .env is the dependency-free option”Unquoted spaces break it, and $(…) in the file executes as you
${OTHER_VAR} will expand”Compose expands it in .env; dotenv leaves it literal — opposite defaults
\n gives me a newline”Only inside double quotes, and only in parsers that unescape — bash leaves it as two characters
”My .env value overrides the environment”dotenv skips keys already present in process.env unless you pass override