Environment Variables in Docker Compose Without the Confusion

by Eric Hanson, Backend Developer at Clean Systems Consulting

The variable that should be overriding but isn't

You've set LOG_LEVEL=debug in your shell. Your docker-compose.yml references ${LOG_LEVEL}. You run docker compose up, exec into the container, and echo $LOG_LEVEL prints info. The .env file in the project directory has LOG_LEVEL=info. Compose read the .env file instead of your shell variable, and you just spent twenty minutes debugging why your debug logs aren't appearing.

This is the precedence problem. Docker Compose has five or six ways to set environment variables and they override each other in a specific order that's not always intuitive.

The precedence order, top to bottom

From highest to lowest priority:

  1. Values set in the environment: block of the service definition (hardcoded)
  2. Variables from the shell environment where docker compose runs
  3. Variables from the .env file in the project directory (or the --env-file file)
  4. Compose file variable substitution defaults (${VAR:-default})

Wait — the shell environment is higher priority than .env? Yes. But only for variable substitution in the Compose file itself, not for what the container receives.

The confusion comes from two distinct phases:

Phase 1: Compose file evaluation. Before Compose starts any containers, it reads the Compose file and substitutes variables. For this substitution, shell environment variables take precedence over the .env file.

Phase 2: Container environment. What gets passed into the container depends on how the service's environment: block is configured.

The conflict in the example: if LOG_LEVEL=info is in .env and the Compose file has:

environment:
  LOG_LEVEL: ${LOG_LEVEL}

Then the Compose file evaluation uses the shell's LOG_LEVEL=debug to evaluate ${LOG_LEVEL}, so the service gets LOG_LEVEL=debug. Actually this would work correctly. The problem arises when the environment: block directly references the .env file via env_file::

env_file:
  - .env

With env_file:, the file is read and its contents are passed directly into the container. Shell variables don't override this — env_file: is not a substitution mechanism, it's a direct pass-through.

The three mechanisms and when to use each

environment: with variable substitution — the most flexible:

services:
  app:
    environment:
      DATABASE_URL: ${DATABASE_URL}
      LOG_LEVEL: ${LOG_LEVEL:-info}    # default of info if not set
      DEBUG: ${DEBUG:-false}

Values come from shell environment or .env file (shell wins). Defaults are expressed inline. This is the most readable and recommended approach for most cases.

env_file: — for passing many variables from a file:

services:
  app:
    env_file:
      - .env
      - .env.local      # optional local overrides

env_file: reads each file line by line and passes KEY=VALUE pairs directly into the container. Multiple files are processed in order; later files override earlier ones. Shell variables do NOT override env_file: values.

Use env_file: when you have many variables and don't want to enumerate them in the Compose file. But be aware: you lose the ability to see which variables a service uses just by reading the Compose file.

Hardcoded values — only for non-sensitive, rarely-changing values:

environment:
  TZ: Asia/Singapore
  LANG: en_US.UTF-8

The .env file: what it does and doesn't do

The .env file is read by Compose before the Compose file is evaluated. Its purpose is to provide values for variable substitution in the Compose file itself — things like image tags, port numbers, and other values you want to be configurable without changing the file.

# .env
APP_VERSION=1.4.2
DB_PORT=5432
# docker-compose.yml
services:
  app:
    image: your-registry/your-app:${APP_VERSION}
  db:
    ports:
      - "${DB_PORT}:5432"

The .env file is NOT automatically passed into containers. The variables are available for Compose file substitution, but a service won't have APP_VERSION in its environment unless you explicitly put it in the environment: block.

This trips people up constantly. docker compose exec app env doesn't show .env variables unless they appear in an environment: block.

A practical setup that avoids confusion

Use the .env file for Compose-level configuration (image versions, host ports). Use a separate file for application secrets, loaded via env_file: or explicit environment: entries:

.env            # Compose config, committed as .env.example, gitignored
.env.app        # Application env vars, gitignored
.env.example    # Template for both, committed
services:
  app:
    image: your-registry/your-app:${APP_VERSION}
    env_file:
      - .env.app
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}   # Compose-level, overridable from shell
    ports:
      - "${APP_PORT:-8080}:8080"

.env.example documents everything:

# Compose configuration
APP_VERSION=latest
APP_PORT=8080
LOG_LEVEL=info

# Application secrets (put these in .env.app)
DATABASE_URL=postgresql://user:password@db:5432/mydb
JWT_SECRET=changeme

Debugging variable resolution

When you're not sure what a service will receive:

# Show what Compose resolves variables to (Compose file substitution)
docker compose config

# Show environment variables inside a running container
docker compose exec app env | sort

# Show what env_file Compose would load
docker compose config --format json | jq '.services.app.environment'

docker compose config outputs the fully-resolved Compose file with all substitutions applied. This is the ground truth for what Compose will actually do.

The variable precedence for containers, precisely

Once you know the above, the container environment precedence is:

  1. environment: block values (highest — explicitly set in Compose file)
  2. env_file: values (in order of files listed, later overrides earlier)
  3. Variables with defaults not overridden by any of the above

Shell environment variables only participate via the Compose file evaluation phase (step 1 in the Compose file substitution chain). Once the environment: block is evaluated, shell variables don't directly influence the container — only what ended up in the environment: block matters.

Write this down somewhere your team can find it. The number of incidents I've seen caused by "my environment variable isn't being passed to the container" is significant, and the answer is almost always a misunderstanding of which mechanism takes precedence.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Are Tech Leads Still Needed in the AI Era?

With AI tools writing code, reviewing pull requests, and generating documentation, you might wonder if tech leads are becoming obsolete.

Read more

Prague Has World-Class Backend Engineers — SAP, Siemens and Automotive Giants Hire Them First

Czech engineering talent is genuinely strong. The enterprise companies that figured this out a decade ago have had first pick ever since.

Read more

Melbourne Is Not Sydney — And Its Backend Hiring Challenges Are Entirely Its Own

Melbourne has a strong tech community and a distinct startup culture. Its backend hiring market has its own specific friction that founders often don't see coming.

Read more

Enumerable's Overlooked Half: The Methods You Should Be Using Instead of each

Most Ruby codebases use each, map, and select for everything. Enumerable ships with 60+ methods, and a dozen of them would eliminate entire blocks of hand-rolled iteration logic sitting in your codebase right now.

Read more