Environment Variables in Docker Compose Without the Confusion

by Arif Ikhsanudin, Backend Developer

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

Why Warsaw Startups Are Hiring Async Remote Backend Contractors to Stay Ahead of Local Salary Inflation

Warsaw's backend salaries have been climbing steadily. The startups moving fastest have found a way to get work done that doesn't depend on where the local market lands next.

Read more

Git Bisect: The Fastest Way to Find Which Commit Broke Everything

When a regression appears and you don't know which of the last fifty commits caused it, git bisect performs a binary search through your history and finds the culprit in six or seven steps.

Read more

Why Your Commit History Tells More About You Than Your Code Does

Your code shows what you built. Your commit history shows how you think — whether you work in logical units, whether you communicate intent, and whether you consider the people who come after you.

Read more

Why Most Software Problems Are Communication Problems

When software goes wrong, it’s rarely the code itself. Most problems start with unclear expectations, misaligned priorities, or missed context.

Read more