Docker Compose Environment and Secrets
Four different things all called environment, doing four different jobs. Which one substitutes into the file, which one reaches the process, and where a password should actually live - resolved by running all of them at once and reading the result.
Docker Compose Guide 27 of 46 Intermediate
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Docker Compose5.4.0
- Architectureamd64
- TimeAbout 14 min
- Reviewed21 August 2026
Tested on the versions above. Nothing here depends on host specifics. The values shown are the ones the commands produced.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- A working Compose project - guide 24 in this path.
- ARG versus ENV at image build time - guide 15 in this path.
-
Four mechanisms, one file
The confusion here is worth naming up front.
.envsupplies values that are substituted INTO the Compose file.environment:sets variables in the container.env_file:loads a file of variables into the container.secrets:mounts a file at/run/secrets/. This project uses all four at once so the difference is visible rather than theoretical.bash cat .envGREETING=from-dotenvTAG=3.22cat compose.yamlservices: app: image: alpine:${TAG} environment: GREETING: ${GREETING} env_file: - app.env secrets: - api_token secrets: api_token: file: ./secret.txtExpected resultOne service using variable substitution, an inline variable, an env file and a secret.
Success conditionYou can point at which line does which job. Note
${TAG}is used in the IMAGE NAME - that only works because.envsubstitutes before the file is parsed. -
Resolve the file before running it
docker compose configprints the file with every substitution applied. This is the single most useful debugging command in Compose: it shows what Compose actually understood, rather than what you think you wrote. Run it whenever a value is not what you expected.bash docker compose configname: cg-cfgservices: app: environment: EXTRA: from-envfile GREETING: from-dotenv image: alpine:3.22 secrets: - source: api_token target: /run/secrets/api_tokenExpected result
${TAG}resolved to a real tag, and both variables merged into one environment block.Success conditionThe image line reads
alpine:3.22. Note the env_file variable and the inline one end up in the same place - by the time the container starts they are indistinguishable. -
See what the process actually got
The proof is in the container. Both variables arrived, from two different sources, and the secret is readable as a FILE rather than as a variable - which is the whole point of the secrets mechanism.
bash docker compose up -d Container cg-cfg-app-1 Starteddocker compose logs appapp-1 | greeting=from-dotenv extra=from-envfileapp-1 | secret=s3cr3t-valueExpected resultBoth variables present, and the secret read from
/run/secrets/api_token.Success conditionAll three values arrived by their own route. The secret was never an environment variable at any point.
-
Why a secret is a file, not a variable
Environment variables leak. They appear in
docker inspect, incompose configoutput, in crash reports and in any child process's environment, and they are trivial to dump accidentally into a log. A secret is mounted as a file readable by the process and nothing else, so the value never appears in the container's metadata. For a local file-backed secret the protection is modest but the habit is the right one, and it is the same syntax a managed secret store plugs into later.bash # an environment variable is visible to anyone who can inspect the containerdocker compose exec app printenv GREETINGhello from the environmentdocker compose exec app cat /run/secrets/api_tokenExpected resultThe variable printed from the environment; the secret only readable from its path.
Success conditionYou understand why passwords belong in
secrets:. Also: never commit the secret file - add it to.gitignorealongside.env. -
Precedence, from weakest to strongest
When the same variable is set in several places, the strongest wins. From weakest: values in
env_file, thenenvironment:in the Compose file, then a variable exported in your shell, thendocker compose run -e. The rule that surprises people is that your shell's environment beats the.envfile - a stale exported variable in one terminal will quietly override the project default in that terminal only.bash GREETING=from-the-shell docker compose config | grep GREETING# the shell value wins over .envdocker compose config | grep GREETING# back to the .env value in a clean shellExpected resultThe same command reporting different values depending on the shell environment.
Success conditionYou know to check your shell before believing a config file is wrong.
docker compose configis the arbiter. -
Keep .env out of version control
.envholds per-machine values and frequently ends up holding credentials too. The convention is to commit a documented example and ignore the real one, so a new contributor knows which variables exist without receiving anyone's keys.bash cat .gitignore.envsecret.txtdocker compose downExpected resultBoth the env file and the secret file ignored.
Success condition
git statusdoes not offer to commit your credentials.
Troubleshooting
WARN: The "X" variable is not set. Defaulting to a blank string
Why: The Compose file references
${X}but nothing defines it - no.env, no exported variable.Fix:Define it, or give it an inline default with
${X:-fallback}so the file is self-sufficient.bash image: alpine:${TAG:-3.22}docker compose config | head -5The variable is set but the application does not see it
Why: Almost always the
.env-versus-environment:confusion..envsubstitutes into the COMPOSE FILE; it does not automatically reach the container.Fix:To pass it through, name it in
environment:. The shorthand- GREETINGwith no value passes the variable of that name straight through.bash docker compose exec app printenv# the definitive list of what the process actually hasA value with special characters is mangled
Why: The
.envformat is not shell. Quotes are taken literally in some positions,$begins a substitution, and a#can start a comment mid-line.Fix:Escape a literal dollar sign by doubling it (
$$), and check the result withcompose configrather than assuming.bash docker compose config | grep -A3 environmentA password ended up in the image or in a log
Why: It was passed as a build argument or an environment variable. Build args persist in the image history; environment variables are visible to inspect and to every child process.
Fix:Move it to
secrets:and read it from/run/secrets/. For build-time needs use BuildKit build secrets, which are not recorded in the image.bash # check whether a secret is already baked into an image you have shippeddocker history --no-trunc IMAGE | grep -i -m1 "password\|token\|secret"