Why the Same .env File Behaves Differently in Docker, Compose, Node, and Python
There is no single .env specification. The same file read by docker run, Docker Compose, a shell, Node, or Python can produce different values — quotes kept or stripped, $VAR expanded or left literal, a value truncated at a # or not.
The short answer
A .env file has no shared specification. Each tool that reads one implements its own parsing, and those implementations disagree. Feeding a single file to docker run --env-file and to Docker Compose's env_file: produced different values for 10 of 14 tested lines on the versions below — same file, same machine, same Docker installation.
The three differences that cause real bugs: docker run keeps quote characters as part of the value where every other consumer strips them; Compose and the shell expand $VAR from the surrounding environment where docker runand both dotenv libraries pass it through literally; and Node's dotenv truncates a value at a # where python-dotenv keeps it.
None of this is a Docker bug. Compose's behaviour matches its published rules exactly. The problem is that each tool documents only itself, so nothing tells you what happens when one file crosses several of them.
What was measured
One fixture was written once, verified byte-for-byte, then handed to each consumer. Each printed every variable wrapped in brackets so quote characters and trailing whitespace stayed visible. Container runs used alpine:3.
PLAIN=hello
DOUBLE="hello world"
SINGLE='hello world'
HASH=value#suffix
HASH_SPACED=value # comment
TRAILING=value␣␣␣
EMPTY=
DOUBLE_EMPTY=""
SINGLE_EMPTY=''
DOLLAR="$DEMO_HOST_VAR"
LITERAL_DOLLAR='$DEMO_HOST_VAR'
EQUALS=a=b=c
ESCAPE_N="line1\nline2"
ESCAPE_N_SINGLE='line1\nline2'␣ marks a literal trailing space. Results below are observations of these exact versions, not a specification:
- Docker Engine / CLI —
29.6.2 - Docker Compose —
5.3.1 - Node.js —
24.16.0 - dotenv (Node) —
17.4.2 - Python —
3.9.6 - python-dotenv —
1.2.1 - Shell —
zsh 5.9, POSIX set -a; . ./.env - Container image —
alpine:3
Go's godotenv is widely used but was not installed on the test machine, so it is deliberately absent rather than guessed at.
The measured results
Rows where every consumer agreed are marked. Everything else is a divergence you can hit in production.
| Line in the file | docker run --env-file | Compose env_file: | Compose interpolation | shell source | node dotenv | python-dotenv |
|---|---|---|---|---|---|---|
| PLAIN=hello | hello | hello | hello | hello | hello | hello |
| DOUBLE="hello world" | "hello world" | hello world | hello world | hello world | hello world | hello world |
| SINGLE='hello world' | 'hello world' | hello world | hello world | hello world | hello world | hello world |
| HASH=value#suffix | value#suffix | value#suffix | value#suffix | value#suffix | value | value#suffix |
| HASH_SPACED=value # comment | value # comment | value | value | value | value | value |
| TRAILING=value␣␣␣ | value␣␣␣ | value | value | value | value | value |
| EMPTY= | empty | empty | empty | empty | empty | empty |
| DOUBLE_EMPTY="" | "" | empty | empty | empty | empty | empty |
| SINGLE_EMPTY='' | '' | empty | empty | empty | empty | empty |
| DOLLAR="$DEMO_HOST_VAR" | "$DEMO_HOST_VAR" | from-host-env | from-host-env | from-host-env | $DEMO_HOST_VAR | $DEMO_HOST_VAR |
| LITERAL_DOLLAR='$DEMO_HOST_VAR' | '$DEMO_HOST_VAR' | $DEMO_HOST_VAR | $DEMO_HOST_VAR | $DEMO_HOST_VAR | $DEMO_HOST_VAR | $DEMO_HOST_VAR |
| EQUALS=a=b=c | a=b=c | a=b=c | a=b=c | a=b=c | a=b=c | a=b=c |
| ESCAPE_N="line1\nline2" | literal \n | real newline | real newline | literal \n | real newline | real newline |
| ESCAPE_N_SINGLE='line1\nline2' | 'line1\nline2' | literal \n | literal \n | literal \n | literal \n | literal \n |
Table scrolls horizontally. Only PLAIN, EMPTY, and EQUALS produced identical values everywhere.
Quote characters survive docker run, but not Compose
docker run --env-file treats everything after the first = as literal text. It does not strip quotes, trim trailing whitespace, or remove inline comments. Compose does all three.
DOUBLE="hello world"
docker run --env-file -> ["hello world"] # quotes are in the value
Compose env_file: -> [hello world]This is the failure people notice first, usually as a password, connection string, or token that is rejected because it arrives wrapped in " characters. The same applies to DOUBLE_EMPTY="", which reaches the container as two quote characters under docker run and as an empty string everywhere else.
Docker's CLI reference documents comment handling for --env-file but does not describe quote handling, so this behaviour is easiest to confirm by observation.
Compose expands host variables; docker run does not
This is the difference with the largest blast radius. Compose applies interpolation to unquoted and double-quoted values, resolving ${VAR} against the environment Compose itself is running in. docker run --env-file performs no substitution.
# .env
FROM_HOST="${DTHQ_SECRET}"
$ DTHQ_SECRET=leaked-from-host docker compose run --rm t
FROM_HOST [leaked-from-host] # expanded from the host shell
$ DTHQ_SECRET=leaked-from-host docker run --rm --env-file .env alpine:3 …
FROM_HOST ["${DTHQ_SECRET}"] # passed through untouchedA reference to a variable that does not exist in the host environment resolves to an empty string under Compose rather than failing, so a misspelled name produces a silently empty value instead of an error. Single-quoted values opt out: Compose used '$HOME' literally, matching its documented rule that single-quoted values are not interpolated.
Node and Python disagree about #
The two most widely used language loaders do not agree with each other on the same line:
HASH=value#suffix
docker run --env-file -> [value#suffix]
Compose env_file: -> [value#suffix]
shell source -> [value#suffix]
python-dotenv 1.2.1 -> [value#suffix]
dotenv 17.4.2 (Node) -> [value] # truncated at the #Node's dotenv treats # as starting a comment even with no whitespace before it; everything else requires a preceding space. A # inside a password, a URL fragment, or a colour value is therefore silently truncated in Node and preserved everywhere else — the kind of difference that only surfaces when one service in a stack starts failing to authenticate.
With a space before it, HASH_SPACED=value # comment, every consumer except docker run stripped the comment.
Compose's two mechanisms agreed
Compose reads env files in two unrelated roles, and conflating them causes its own class of confusion: env_file:passes variables into the container, while the project's root .env (or an explicit --env-file) supplies values interpolated into the Compose file itself.
On the versions tested, both paths produced identical values for every line in the fixture, and the explicit --env-file flag matched the implicit root .env. That is a measured result for these versions, not a guarantee that the two mechanisms are specified to behave identically — they are documented separately and could diverge.
Writing a file that survives the crossing
There is no syntax that means the same thing everywhere, so the practical goal is to stay inside the subset every consumer agreed on.
- 1. Leave values unquoted when they contain no spacesPLAIN=hello and EQUALS=a=b=c were the cases every consumer agreed on. Quotes are the single biggest source of divergence, because the CLI keeps them and everything else strips them.
- 2. Never rely on quotes to protect a valueA quoted value reaches the container with the quote characters attached under docker run --env-file, and without them under Compose. If a value needs spaces, prefer passing it through the shell or an environment block rather than a shared file.
- 3. Keep # out of values, or expect truncationvalue#suffix survived docker run, Compose, and the shell, but node dotenv 17.4.2 truncated it to value. A # inside a password or a URL fragment is a silent data-loss risk that depends entirely on who reads the file.
- 4. Avoid $ unless you intend expansionCompose and the shell expand $VAR from the surrounding environment; docker run and both dotenv libraries do not. A literal $ in a secret will survive some paths and not others.
- 5. Do not depend on trailing whitespace or empty quoted valuesTrailing spaces survived only docker run. "" arrived as two quote characters there and as an empty string everywhere else.
- 6. Use one mechanism per file where you canMost of this divergence only bites when a single file is shared across tools. A file read by exactly one consumer behaves predictably, whatever that consumer does.
How long this has been true
The docker run versus Compose divergence is not new. It was reported publicly in docker/compose issue 8388 in June 2021 against docker-compose 1.29.2, describing quoted values reaching the container unquoted and noting the inconsistency with docker run. The issue was closed under a documentation label, and Compose's rules are now specified precisely in its reference.
What the measurements above add is that the divergence itself still reproduces on Docker 29.6.2 and Compose 5.3.1. A user report describes what someone observed on one version; it does not define intended behaviour. Treat the 2021 thread as history and the table above as the current state.
Cleaning up the file itself
Once you know which consumer is changing your value, the remaining work is usually to the file: duplicate keys where the last one silently wins, inconsistent quoting across sections, entries that have drifted out of order and are hard to diff.
The ENV Formatter normalises formatting, sorts keys, and removes duplicates so the file is readable and reviewable. It does not emulate Docker, Compose, or dotenv parsing, and it cannot tell you whether a value will survive a particular consumer — that is what the table above is for. Formatting runs in your browser, so a file full of real credentials is not uploaded anywhere.