Docker Compose eats the dollar signs in your env_file
A bcrypt hash goes into an env_file and comes out shorter inside the container. Nothing crashes, the right password is simply refused. Here is what Compose does to every $ in that file, and the one line that stops it.
Contents
The setup was ordinary: Caddy in front of a staging server, protected by basic auth. The password hash went into an env_file, Caddy read it from the environment, and the login page refused the password I had just hashed. Same password, same hash, every time.
The symptom
Print the variable from inside the container and the problem is visible at once: a slice of the hash is gone.
in $2y$14$yYt.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NES
out $2y$14.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NESFour characters missing, and every login fails. Caddy does not complain about the hash: it is still a well-formed string, it just matches no password.
The cause
Compose interpolates env_file files like the rest of its configuration. Every $ is read as the start of a variable reference, and a variable that is not set expands to an empty string.
A bcrypt hash is made of $-separated fields. Compose reads it like this:
| Fragment | Read as | Result |
|---|---|---|
$2y |
Not a variable: names can't start with 2 | Kept |
$14 |
Same | Kept |
$yYt |
The variable yYt, up to the . |
Empty |
.xTm... |
Plain text | Kept |
How Compose parses a bcrypt hash, fragment by fragment.
Which characters disappear depends on the salt. Only a salt that starts with a letter is cut: one that starts with a digit, a dot or a slash survives untouched. The bug can come and go each time you regenerate the hash.
Compose does warn you, once, among the startup lines:
level=warning msg="The \"yYt\" variable is not set. Defaulting to a blank string."It is easy to miss in the output of a full stack. Once you know, it names the missing fragment exactly.
The fix: format: raw
Since Compose 2.30, an env_file entry accepts a format attribute. raw reads the file with the same parser as docker run --env-file: no interpolation, every character kept as written1.
services:
caddy:
image: caddy:2
env_file:
- path: .env.caddy
format: rawPaste the hash exactly as the command printed it: no quotes, no escaping.
The other ways out
I tested each one with Docker Compose v5.3.1, by printing the variable from an Alpine container.
| Written in the env_file | format: raw |
Received intact |
|---|---|---|
HASH=$2y$14$yYt... |
no | no |
HASH="$2y$14$yYt..." |
no | no |
HASH='$2y$14$yYt...' |
no | yes |
HASH=$$2y$$14$$yYt... |
no | yes |
HASH=$2y$14$yYt... |
yes | yes |
Five ways to write the same hash, and what reaches the container.
Single quotes work, as in a shell. Doubling every $ works too, but the file no longer holds the hash, only an escaped version of it. That is the one you will paste back into the wrong place six months later.
I prefer format: raw: the file contains the value and nothing else, and the intent sits in compose.yaml where the next reader will see it.
A separate file is a good idea anyway
Putting the hash in its own .env.caddy, instead of the main .env, has a second benefit. The reverse proxy is the one container facing the internet. It has no reason to read the database URL, the JWT secret or the payment API key, and passing it the full .env gives it all of them.
The reverse trap: source is not Compose
When debugging an env file, the reflex is to load it in a shell and look. That tests the shell, not Compose, and the two disagree in both directions.
set -a; . ./.env.caddy; echo "$HASH"
# y.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NESThe shell mangles the hash even more than Compose did: $2 and $1 are positional parameters, so they disappear as well. The opposite also happens. This line is fine for Compose, which reads KEY=VALUE without a shell, and breaks source:
APP_NAME=My App (beta)./.env: line 1: syntax error near unexpected token `('To see what a container really receives, ask the container:
docker compose run --rm caddy printenv HASHFootnotes
-
The
formatattribute was added in Docker Compose 2.30.0. On an older version, use single quotes or$$. ↩