Shares information on new learnings, in hopes that it will be useful to others. Perhaps even to you.

Topics revolve around programming, but not exclusively.

Usage: developing-human posts | projects | readings

developing-human posts docker-compose-env-priority

Why docker compose prioritizes shell environment over .env

created: 2025-11-10

Last week I was surprised when docker compose prioritized the shell environment's values over .env. Initially I resolved this by removing the environment variable from my .bashrc. But after reading the docs (here and here) and digging into why docker behaves this way, I found a larger issue with my workflow.

Verifying my understanding

To verify I setup a small docker compose project with two variables: SHELL_AND_DOT_ENV and ONLY_DOT_ENV.

compose.yml:

services:
  echoer:
    build: .
    environment:
      SHELL_AND_DOT_ENV: ${SHELL_AND_DOT_ENV}
      ONLY_DOT_ENV: ${ONLY_DOT_ENV}

Dockerfile:

FROM alpine:3.20
CMD echo "SHELL_AND_DOT_ENV is: $SHELL_AND_DOT_ENV" && \
    echo "ONLY_DOT_ENV is: $ONLY_DOT_ENV"

.env:

SHELL_AND_DOT_ENV=hello-from-dot-env
ONLY_DOT_ENV=hello-from-dot-env

And running it...

$ SHELL_AND_DOT_ENV=hi-from-os docker compose up --build
...snip...
echoer-1  | SHELL_AND_DOT_ENV is: hi-from-os
echoer-1  | ONLY_DOT_ENV is: hello-from-dot-env

Understanding why

According to the interpolation docs the shell environment takes precedence to make it possible to override a variable from .env when running a command. Which, ironically, is how I decided to test this. So it's working as intended, and my mental model was mistaken.

Docker is assuming I'm following the best practice of not putting project specific variables in my global environment.

Getting the value from .env

In my (mistaken) view, I had expected SHELL_AND_DOT_ENV to say "hello-from-dot-env" because I view it as the OS environment existing, and .env being applied on top of it since it is more specific.

To get the .env to take priority, I found two ways:

Don't define variable at the shell level

First, don't have the environment variable defined in my environment:

~/code/docker-echo-test $ docker compose up --build
...snip...
echoer-1  | SHELL_AND_DOT_ENV is: hello-from-dot-env
echoer-1  | ONLY_DOT_ENV is: hello-from-dot-env

The downside of this approach is that if I later define SHELL_AND_DOT_ENV in my .bashrc, this project will be affected.

Use env_file to explicitly point to .env

Alternatively, I can define the values via env_file instead of environment in compose.yml:

services:
  echoer:
    build: .
    env_file: .env

Which gives:

~/code/docker-echo-test $ SHELL_AND_DOT_ENV=hi-from-os docker compose up --build
...snip...
echoer-1  | SHELL_AND_DOT_ENV is: hello-from-dot-env
echoer-1  | ONLY_DOT_ENV is: hello-from-dot-env

This skips the interpolation step which is prioritizing the shell environment over .env, which solves the issue. In a more realistic compose.yml I would define a .env per service, to keep environment variables from leaking between services. For example, ServiceA doesn't need or want API keys that are only used by ServiceB.

The downside of this approach is it splits the compose.yml into more files, which can add unnecessary complexity depending on the project size. For a small project, it feels like too much. For a large project, the extra layer of organization may be helpful.

Conclusion

In my situation, I opted to stick with my original solution of removing the problematic environment variable from my .bashrc. But also... to move all my project specific variables out of my .bashrc, which is the real lesson here.

Surprisingly, digging into why docker compose was behaving differently than I expected helped me reveal a larger problem with my workflow.