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.

Published
Reading time
4 min
Language
English
Contents
  1. The symptom
  2. The cause
  3. The fix: format: raw
  4. The other ways out
  5. A separate file is a good idea anyway
  6. The reverse trap: source is not Compose

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.

What went in, what came out
in   $2y$14$yYt.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NES
out  $2y$14.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NES

Four 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:

docker compose up
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.

compose.yaml
services:
  caddy:
    image: caddy:2
    env_file:
      - path: .env.caddy
        format: raw

Paste 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.

Terminal
set -a; . ./.env.caddy; echo "$HASH"
# y.xTmFGzgxePglx4ENZOrY1hLqb1Cpt5Aw5.2exzJqcZvbW8NES

The 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:

.env
APP_NAME=My App (beta)
Terminal (bash)
./.env: line 1: syntax error near unexpected token `('

To see what a container really receives, ask the container:

Terminal
docker compose run --rm caddy printenv HASH

Footnotes

  1. The format attribute was added in Docker Compose 2.30.0. On an older version, use single quotes or $$. ↩