One .env file, four parsers: Node, Python, Docker, systemd
The same .env line can give three different values. We ran one file through Node, dotenv, python-dotenv, Compose and docker run, and read systemd's rules.
Crunu teamPublished 7 min read
On this page
There is no single .env format. Each loader has its own rules for comments, quotes, $ references, export and line breaks, and they disagree on ordinary-looking lines. In our test, the line HASH=abc#123 came out as abc in Node.js and the dotenv package, but as abc#123 in python-dotenv, Docker Compose and docker run. docker run --env-file kept quotes as part of the value and refused a whole file because of one export line. A .env that works on a laptop can therefore give a container or a systemd service a different value without any error. The safe subset is short: one KEY=value per line, no inline comments, no references, double quotes only when needed, and a check before the file is used somewhere new.
The tables below come from running one file through each loader on October 30, 2026, plus systemd's manual for the one we read rather than ran. The guide to sharing .env files with a team covers getting the file to people in the first place.
The test file
PLAIN=abc
HASH=abc#123
SPACEHASH=abc #123
DQ="quoted value"
SQ='single $HOME'
EXPANDED=$HOME/x
BRACED=${HOME}/x
export EXPORTED=yes
MULTI="line1
line2"Every line is something people write in real files: a password with # in it, a comment at the end of a line, quotes around a value with a space, a reference to another variable, an export so the file can be sourced, and a multiline value such as a certificate.
What each loader read
Tested versions: Node.js 26.10 with --env-file, the dotenv npm package 18.0.9, python-dotenv 1.2.4, Docker Compose 5.0.0 with env_file:, and Docker 29.1.3 with docker run --env-file. HOME was set to /home/test.
| Line | Node --env-file | dotenv (npm) | python-dotenv | Compose env_file | docker run --env-file |
|---|---|---|---|---|---|
HASH=abc#123 | abc | abc | abc#123 | abc#123 | abc#123 |
SPACEHASH=abc #123 | abc | abc | abc | abc | abc #123 |
DQ="quoted value" | quoted value | quoted value | quoted value | quoted value | "quoted value" |
SQ='single $HOME' | single $HOME | single $HOME | single $HOME | single $HOME | 'single $HOME' |
EXPANDED=$HOME/x | $HOME/x | $HOME/x | $HOME/x | /home/test/x | $HOME/x |
BRACED=${HOME}/x | ${HOME}/x | ${HOME}/x | /home/test/x | /home/test/x | not tested |
export EXPORTED=yes | yes | yes | yes | yes | file rejected |
two-line MULTI | two lines | two lines | two lines | two lines | broken |
Three rows deserve a closer look.
The # row. Node's documentation says "Hash-tag (#) characters denote the beginning of a comment", unless they're inside quotes (Node.js: environment variables). Docker's documentation for Compose files says the opposite for unquoted values: "Inline comments for unquoted values must be preceded with a space", so VAR=VAL# not a comment keeps everything (Docker: Compose interpolation). A generated password with # in it is cut short in a Node app and complete in a container started from the same file. Nothing fails; the password is just wrong, and the error you see is an authentication failure somewhere else.
The quotes row. For docker run, the documentation says the file uses <variable>=value and that "a # appearing anywhere else in a line is treated as part of the variable value" (Docker: docker container run). It doesn't describe quote handling, and in our test the quotes stayed in the value. A database URL written as DATABASE_URL="postgres://…" reaches the app with a leading ".
The export row. With export EXPORTED=yes in the file, docker run stopped with "variable 'export EXPORTED' contains whitespaces" and didn't start the container at all. Node, by contrast, documents that "the export keyword can optionally be added in front of variable declarations" and is ignored, so the file can also be sourced in a shell.
References: $VAR and ${VAR}
The EXPANDED and BRACED rows show three behaviors from five loaders. Compose expands both $HOME and ${HOME}; its rules say "unquoted and double-quoted values have interpolation applied." python-dotenv expanded only the braced form. Node, dotenv and docker run expanded neither and kept the text as written.
So API_URL=${BASE_URL}/v1 is a working reference in Compose and python-dotenv, and the literal string ${BASE_URL}/v1 in a Node app. A value that contains a real $, such as a generated secret, has the opposite problem in Compose: part of it can be replaced by an empty variable. Docker's rules make single-quoted values literal, so a secret with $ in it belongs in single quotes if Compose will ever read the file.
systemd's EnvironmentFile=
We didn't run systemd for this post; its manual is specific enough to read the answers (systemd.exec):
- Comments are whole lines only: "lines starting with ';' or '#' will be ignored". Nothing says a
#later in the line starts a comment, soHASH=abc#123andSPACEHASH=abc #123keep everything; for unquoted values, "interior whitespace is preserved." - Single-quoted values "can span multiple lines and contain any character verbatim other than single quote." Double-quoted values can also span lines and use shell-like escapes.
- The manual describes no expansion of
$OTHERinside the file and noexportprefix. Write plain assignments. - A missing or unreadable file stops the service: "the service will fail to start", unless the path starts with
-.
That makes systemd stricter than Node about comments and closer to docker run, while handling quotes and line breaks like Compose. A file written for one of them needs checking before it's used by another.
Why nobody notices
None of these differences produce an error, except the export line in docker run. A value cut at # is still a valid string, a value with quotes around it is still a valid string, and a reference left as text is still a valid string. The loader is satisfied, the program starts, and the failure shows up later and elsewhere: a database refusing a password, a webhook signature that never matches, a URL with a stray quote.
Two cheap habits shorten that search. At startup, have the program check that each required variable is set and has a plausible length, and log the name and length on failure, never the value. And when a service fails only in one place, compare how that place loads its variables before you compare the values themselves.
Testing your own file without exposing it
The quickest way to know how your loaders read your file is to run them on it, but printing real values to a terminal is exactly what you shouldn't do. Make a copy with the same shape instead: keep every key, quote, #, $ and space, and replace letters and digits in the values with x.
awk '
/^[[:space:]]*#/ { print; next }
match($0, /^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=/) {
k = substr($0, 1, RLENGTH); v = substr($0, RLENGTH + 1)
gsub(/[A-Za-z0-9]/, "x", v); print k v; next
}
{ gsub(/[A-Za-z0-9]/, "x"); print }
' .env > shape.envLines that continue a multiline value are masked too. References change name ($HOME becomes $xxxx), which is fine: a value that suddenly gets shorter tells you the loader expanded it. Then feed shape.env to each loader that will really read the file:
node --env-file=shape.env -e 'console.log(JSON.stringify(process.env, null, 1))'
docker run --rm --env-file shape.env alpine:3.22 envCompare the lengths and the characters that survived. A value that lost everything after a #, kept its quotes or became empty is the line to rewrite. Delete shape.env afterwards anyway; it still lists every name.
A subset every loader agrees on
These rules gave the same value in all five loaders we ran, and fit systemd's manual too:
- One
KEY=valueper line, with names in capitals, digits and_. - Comments only on their own line, starting with
#. - No
export, no$references, no values that start or end with a space. - No quotes at all when the value has no spaces,
#, quotes or$. Generate secrets from letters and digits, or as base64url, and this covers most of them. - Multiline values, such as private keys, kept out of
.envfiles entirely and delivered as files.
When a value can't fit the subset, convert the file for its target instead of hoping. The .env linter flags lines that loaders read differently, and the .env converter writes a Docker --env-file or a systemd EnvironmentFile with each value quoted the way that target reads it, and lists values the format can't hold.
Or don't hand the file to the program at all
The disagreement exists because each program parses the file itself. If the program receives its environment variables already set, there's nothing to parse. That is how crunu run works: the values are stored per environment in Crunu, decrypted on the machine, and placed directly into the program's environment before it starts.
crunu run --env web-app/production -- node server.jsNode doesn't read a .env here, so #, quotes and $ in a value arrive exactly as stored. The same command works in front of a Python app, a container entry point or a binary. For a systemd service, either start the program through crunu run in ExecStart=, or let crunu pull --env-out write one file meant for EnvironmentFile=; the systemd integration shows both. Stack-specific notes are on the pages for Docker Compose and Node.js.
Shell loading has its own risks beyond parsing: a line in a sourced file can run a command. Loading env files into a shell covers that case.
Checklist
Sources
- Environment variables, Node.js documentation, checked October 30, 2026.
- Set, use, and manage variables with interpolation, Docker Docs, checked October 30, 2026.
- docker container run, Docker Docs, checked October 30, 2026.
- systemd.exec, freedesktop.org, checked October 30, 2026.