Handling Multiline Values and Quotes in .env Files
The app fails with error:1E08010C:DECODER routines::unsupported when loading a private key from .env, a JSON config variable arrives as {"retries": followed by nothing, and a database password containing $ecret#1 authenticates in the app but not in docker compose, which prints WARN[0000] The "ecret" variable is not set. Defaulting to a blank string. The same .env file is being read by three different parsers — the application's dotenv library, Docker Compose and sometimes a shell source — and each treats quotes, $, # and newlines differently. This page makes awkward values parse identically everywhere, as part of dotenv configuration management.
There is no official .env format specification, which is why the same line can mean different things to different tools.
Diagnostic
Print what each consumer actually receives for the problem variables:
#!/usr/bin/env bash
set -euo pipefail
node -e "require('dotenv').config(); for (const k of ['JWT_PRIVATE_KEY','APP_CONFIG','DB_PASSWORD']) console.log(k, JSON.stringify((process.env[k]||'').slice(0,40)), (process.env[k]||'').length)"
docker compose config --format json | jq -r '.services.api.environment | {JWT_PRIVATE_KEY: (.JWT_PRIVATE_KEY // "" | .[0:40]), APP_CONFIG, DB_PASSWORD}'
bash -c 'set -a; . ./.env; set +a; printf "%s|%s\n" "${DB_PASSWORD}" "${APP_CONFIG:0:20}"' 2>&1 | head -2
Expected bad output:
JWT_PRIVATE_KEY "-----BEGIN PRIVATE KEY-----" 27
APP_CONFIG "{\"retries\":" 11
DB_PASSWORD "$ecret#1" 8
{ "JWT_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBg", "APP_CONFIG": "{\"retries\": 3}", "DB_PASSWORD": "" }
|{"retries":
The Node dotenv parser kept only the first line of the key and cut the JSON at a space; Compose kept literal \n sequences in the key and interpolated $ecret to an empty string; the shell mangled both. Three consumers, three different values.
Root cause
Every dotenv implementation defines its own grammar. Node's dotenv supports double-quoted multiline values and expands \n inside double quotes, treats # after whitespace as a comment in unquoted values, and does not interpolate $ (without dotenv-expand). Docker Compose's parser interpolates $VAR and ${VAR} in unquoted and double-quoted values, does not interpolate inside single quotes, and supports multiline values only in quoted form. A POSIX shell sourcing the file applies shell rules: word splitting on unquoted spaces, $ expansion in double quotes, and # comments. Values that avoid all special characters work everywhere; values containing newlines, spaces, $, # or quotes need a form every consumer interprets the same way — or need to not be stored as raw text in a .env file at all.
These bugs are hard to diagnose because the failure appears far from the cause. A truncated private key produces a cryptography error deep inside a JWT library; a password that lost its $ecret segment produces an authentication failure from the database; a JSON value cut at a space produces a parse error or, worse, silently falls back to defaults. None of those messages mentions the .env file. The diagnostic above short-circuits the hunt by printing what each consumer actually received, which is almost always enough to see the problem immediately. Printing only a prefix and the length keeps real secrets out of terminal scrollback while still revealing truncation.
Resolution
- Single-quote values containing
$or#. Single quotes disable interpolation in Compose and in shells, and dotenv reads them literally:
DB_PASSWORD='$ecret#1'
REDIS_URL='redis://:p$ss#word@cache:6379/0'
In a Compose file itself (not the env file), escape a literal $ as $$.
- Store multiline secrets as base64 so they are a single line with no special characters, and decode in the application:
#!/usr/bin/env bash
set -euo pipefail
b64=$(base64 < keys/jwt-private.pem | tr -d '\n')
grep -v '^JWT_PRIVATE_KEY_B64=' .env > .env.tmp || true
printf "JWT_PRIVATE_KEY_B64='%s'\n" "$b64" >> .env.tmp && mv .env.tmp .env
const privateKey = Buffer.from(process.env.JWT_PRIVATE_KEY_B64, 'base64').toString('utf8');
if (!privateKey.startsWith('-----BEGIN')) throw new Error('JWT_PRIVATE_KEY_B64 did not decode to a PEM key');
Base64 survives every parser unchanged, which is why it is the most portable choice for keys and certificates.
- Or mount multiline values as files instead of variables, which avoids parsing entirely:
services:
api:
environment:
JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key
secrets:
- jwt_private_key
secrets:
jwt_private_key:
file: ./keys/jwt-private.pem
The application reads the path from JWT_PRIVATE_KEY_FILE — the same convention official database images use for POSTGRES_PASSWORD_FILE.
- Write JSON on one line in single quotes, or split it into separate variables:
APP_CONFIG='{"retries": 3, "timeout_ms": 2500}'
- Stop sourcing
.envin shells. Use the tool that owns the file —docker compose run,dotenv -- commandor direnv'sdotenv— so one parser reads it.
Expected output
$ node -e "require('dotenv').config(); const k = Buffer.from(process.env.JWT_PRIVATE_KEY_B64,'base64').toString(); console.log(k.split('\n')[0], k.length); console.log(process.env.DB_PASSWORD, JSON.parse(process.env.APP_CONFIG).retries)"
-----BEGIN PRIVATE KEY----- 1704
$ecret#1 3
$ docker compose config --format json | jq -r '.services.api.environment | .DB_PASSWORD, .APP_CONFIG'
$ecret#1
{"retries": 3, "timeout_ms": 2500}
The key decodes to the full PEM, the password keeps its $ and #, and the JSON parses — identically in the application and in Compose, with no interpolation warnings.
The file form is worth preferring for anything that is genuinely a file — keys, certificates, service-account JSON. It keeps the secret out of the process environment, where it could otherwise appear in crash dumps, docker inspect output or child processes that inherit the environment. Many libraries accept a path directly, so the application does not even need to decode anything.
Prevention
- Lint env files for values containing
$,#, spaces or quotes outside single quotes:
#!/usr/bin/env bash
set -euo pipefail
bad=$(grep -nE "^[A-Z0-9_]+=[^'].*[\$# \"]" .env.example || true)
[ -z "$bad" ] && echo "env file values safely quoted" || { echo "quote these values with single quotes:"; echo "$bad"; exit 1; }
Validate decoded values at startup — a key that does not start with
-----BEGINor JSON that does not parse should stop the application with a clear message, as covered in fixing boolean and number env coercion bugs.Document the conventions at the top of
.env.example: single quotes for special characters,_B64suffix for base64 values,_FILEsuffix for mounted files.
Platform caveats
Windows:
.envfiles saved with CRLF endings carry a trailing\rinto values with some parsers. Force LF with.env* text eol=lfin.gitattributes.
macOS:
base64on macOS wraps lines differently from GNUbase64;tr -d '\n'in the script makes the output single-line on both.
CI secret stores: most CI systems handle multiline secrets natively; decode base64 values in the job or write them to files with restrictive permissions rather than exporting multiline variables.
Rollback
Revert the env file conventions and application decoding together:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .env.example src/config
Frequently Asked Questions
Why does my password with $ work in the app but not in Docker Compose?
Compose interpolates $name in env files unless the value is single-quoted, so $ecret becomes an empty variable. Wrap the value in single quotes, or write $$ inside Compose YAML.
How should I put a PEM private key in a .env file?
Preferably not as raw text. Base64-encode it into one line and decode it in the application, or mount the key as a file and pass its path in a _FILE variable.
Why is my JSON value truncated?
An unquoted value is cut at whitespace or treated as a comment after # by some parsers. Put JSON on one line inside single quotes.
Can I just source .env in my shell scripts?
It applies shell rules that differ from dotenv and Compose rules, so values with spaces, $ or quotes change meaning. Use a dotenv-aware runner or Compose to read the file instead.