Defining Variables in Docker Compose: Interpolation, environment, env_file and .env

devops

Defining Variables in Docker Compose: Interpolation, environment, env_file and .env

last updated 2026-10-25Daniel Corneschi8 min read

Docker Compose has several ways to configure a service, and they operate at two distinct layers that are easy to confuse:

  • Interpolation substitutes ${VAR} inside the Compose file itself, before the file is parsed. Values come from your shell or the project .env file.
  • Container environment sets variables inside the running container, using environment or env_file.

This guide walks through both layers using timezone (TZ) as the running example, then covers build arguments, precedence, and debugging. Every example and command here was run with Docker Compose v5.6.1 and Docker Engine 29.9.2.

The most common mistake: assuming the project .env file is passed into containers. It is not. .env feeds ${...} interpolation in the Compose file. To put a value inside the container you must reference it under environment, or load a file with env_file.

Two layers: the shell and the project .env fill the ${…} placeholders in compose.yaml, the shell winning over .env; the container environment is built from docker compose run -e, environment, env_file and image ENV, highest first; build args exist only during the build unless promoted to ENV; .env itself never reaches the container

Methods Overview

MethodLayerUse for
environmentContainerRuntime variables the app reads, such as TZ
env_fileContainerLoading many container variables from a file
Project .envInterpolationFilling ${...} in the Compose file
build.argsBuild timeValues needed while building the image
Compose variablesInterpolationImage tag, ports, project name

Method 1: Container Environment Variables

environment sets variables inside the container. It accepts two equivalent forms.

List form:

services:
  app:
    image: alpine:3.20
    environment:
      - TZ=America/New_York
      - LOG_LEVEL=debug

Map form:

services:
  app:
    image: alpine:3.20
    environment:
      TZ: "America/New_York"
      LOG_LEVEL: "debug"

Both put TZ=America/New_York in the container, but on alpine:3.20 date still prints UTC: the image has no timezone database. See the timezone example below.

With defaults from interpolation

${TZ:-UTC} is resolved by Compose before the container starts, using the shell or project .env. If TZ is unset or empty, the default UTC is used:

services:
  app:
    image: alpine:3.20
    environment:
      - TZ=${TZ:-UTC}
      - NODE_ENV=${NODE_ENV:-production}
      - PORT=${PORT:-3000}

Interpolation default forms:

FormBehavior
${VAR}Value of VAR, or empty if unset
${VAR:-default}default if VAR is unset or empty
${VAR-default}default only if VAR is unset
${VAR:?error}Fail with error if VAR is unset or empty
${VAR:+value}value if VAR is set and not empty, otherwise empty

Passing a variable through from the shell

Listing a key with no value passes it from the environment Compose was run in:

services:
  app:
    image: alpine:3.20
    environment:
      - TZ          # inherits TZ from the shell/.env, if set

Method 2: The Project .env File

Compose automatically reads a file named .env in the project directory and uses it for ${...} interpolation. This is interpolation only; it does not inject variables into containers by itself.

# .env (same directory as the compose file)
TZ=Europe/London
IMAGE_TAG=1.27.1
APP_PORT=8080
services:
  app:
    image: nginx:${IMAGE_TAG}
    ports:
      - "${APP_PORT}:80"
    environment:
      - TZ=${TZ}          # .env value reaches the container only via this line

Without the TZ=${TZ} line under environment, the container would not receive TZ even though it is defined in .env. docker compose config shows it: a service without the line has no environment key at all, while one with it shows TZ: Europe/London.

Method 3: Loading Container Variables from a File with env_file

env_file loads variables directly into the container, without needing a matching environment line for each one. This is the file-based counterpart to environment.

# app.env
TZ=Asia/Tokyo
LOG_LEVEL=debug
DATABASE_URL=postgres://localhost/app
services:
  app:
    image: alpine:3.20
    env_file:
      - app.env
      - secrets.env

env_file differs from the project .env:

AspectProject .envenv_file
PurposeInterpolation in the Compose fileVariables inside the container
LoadedAutomatically, one fileExplicitly, one or more files
Reaches containerOnly if referenced under environmentDirectly

Every listed file must exist, or Compose stops with env file .../secrets.env not found. Mark a file as optional with the long syntax:

    env_file:
      - path: app.env
      - path: secrets.env
        required: false

When multiple env_file entries define the same key, later files win. Values set under environment override values from env_file.

services:
  app:
    image: alpine:3.20
    env_file:
      - ./base.env         # loaded first
      - ./override.env     # overrides base.env
    environment:
      - TZ=UTC             # overrides both files for TZ

Method 4: Build Arguments

Build arguments are available only while the image is built, not at runtime. Set runtime values separately with environment.

services:
  app:
    build:
      context: .
      args:
        - TIMEZONE=${TZ:-UTC}
        - USER_ID=${DOCKER_UID:-1000}
    environment:
      - TZ=${TZ:-UTC}       # runtime timezone for the app
# Dockerfile
FROM alpine:3.20
ARG TIMEZONE=UTC
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/$TIMEZONE /etc/localtime && \
    echo "$TIMEZONE" > /etc/timezone
ENV TZ=$TIMEZONE

A build ARG does not persist into the running container unless you promote it to an ENV, as shown with ENV TZ=$TIMEZONE.

Method 5: Compose Configuration Variables

Interpolation also configures the Compose file itself: image tags, ports, and Compose’s own settings such as COMPOSE_PROJECT_NAME.

# .env
COMPOSE_PROJECT_NAME=myapp
IMAGE_TAG=1.0.0
APP_PORT=3000
TZ=Europe/Berlin
services:
  app:
    image: myapp:${IMAGE_TAG}
    ports:
      - "${APP_PORT}:3000"
    environment:
      - TZ=${TZ}

Timezone Example: Four Approaches

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:v19.5.1
    environment:
      - TZ=${TZ:-UTC}

Most applications and language runtimes honor TZ. This requires the image to include the timezone database (tzdata). Minimal images such as alpine don’t have it: with TZ=America/New_York, alpine:3.20 prints UTC, and the same image with tzdata installed prints EDT.

B. Volume mount of host timezone files

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:v19.5.1
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro

This mirrors the host clock configuration into the container. Mount the files read-only. /etc/timezone is Debian-family specific. If the host doesn’t have it, the mount doesn’t fail: Docker creates an empty directory at that host path and mounts it, so /etc/timezone in the container is a directory (cat: read error: Is a directory). Only mount it on hosts that have the file.

C. Both (belt and suspenders)

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:v19.5.1
    environment:
      - TZ=${TZ:-UTC}
    volumes:
      - /etc/localtime:/etc/localtime:ro

TZ covers applications that read the variable; the mount covers tools that read /etc/localtime.

D. Host network mode

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:v19.5.1
    network_mode: host

network_mode: host shares the host network namespace. It does not by itself set the timezone; still use TZ or the mount. Host networking has broad security and portability implications, so use it only when the workload genuinely needs it.

Advanced Interpolation Patterns

Compose supports only a subset of Bash’s operators (no pattern replacement, case conversion or substrings: ${VAR/a/b}, ${VAR^^} and ${VAR:0:2} fail with invalid interpolation format), and a literal $ must be written as $$.

Compose nested defaults

services:
  app:
    image: alpine:3.20
    environment:
      - DATABASE_URL=postgres://${DB_USER:-user}:${DB_PASS:-pass}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-app}

Profiles for environment-specific services

services:
  app:
    image: alpine:3.20
    profiles: ["production"]
    environment:
      - TZ=UTC
      - LOG_LEVEL=warn

  app-dev:
    image: alpine:3.20
    profiles: ["development"]
    environment:
      - TZ=America/New_York
      - LOG_LEVEL=debug

Activate a profile with docker compose --profile production up. Profiles select which services run; Compose does not compute one variable’s default from another variable’s value, so keep conditional logic in the application or in the .env you choose.

Precedence

When the same container variable is set in more than one place, Compose resolves it in this order (highest wins):

  1. Values set in the shell and passed via docker compose run -e or the process environment for a passthrough key.
  2. environment in the service.
  3. env_file in the service (later files override earlier ones).
  4. Image ENV defaults from the Dockerfile.

The project .env file does not appear here because it feeds interpolation, not the container environment directly.

Handling Secrets

Keep sensitive values out of the committed Compose file and out of build arguments, since build args are visible in image history.

services:
  app:
    image: alpine:3.20
    environment:
      - TZ=${TZ:-UTC}       # non-sensitive
    env_file:
      - secrets.env          # sensitive values, git-ignored

Add secret files to .gitignore. For stronger handling, use Docker secrets or an external secrets manager rather than plain environment variables.

Debugging Variables

docker compose config renders the fully interpolated configuration, which is the fastest way to see what a ${...} resolved to:

# Show the resolved, merged configuration
docker compose config

# Validate syntax quietly (non-zero exit on error)
docker compose config --quiet

# Inspect a service's resolved environment as JSON
docker compose config --format json | jq '.services.app.environment'

Check what actually reached a running container. exec needs the service to be running: the alpine examples above exit at once, so give them a long-running command such as command: sleep infinity to try this.

# Environment inside the container
docker compose exec app env | grep TZ

# Confirm the effective time and zone
docker compose exec app date

If docker compose config shows an empty value, the variable was unset during interpolation. If config shows the value but the container does not have it, you likely set it in the project .env without referencing it under environment or env_file.

Real-World Example: GitLab Runner

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:v19.5.1
    container_name: gitlab-runner
    restart: always
    environment:
      - TZ=${TZ:-UTC}
      - CI_SERVER_URL=${CI_SERVER_URL}
      - RUNNER_DEBUG=${RUNNER_DEBUG:-false}
    env_file:
      - .env.runner
    volumes:
      - ./config.toml:/etc/gitlab-runner/config.toml:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock

This combines interpolation defaults (${TZ:-UTC}), a container variable file (env_file), and a read-only timezone mount. .env.runner and config.toml must exist next to the Compose file, and an unset CI_SERVER_URL only gives a warning (The "CI_SERVER_URL" variable is not set. Defaulting to a blank string.); use ${CI_SERVER_URL:?} to make it required. The top-level version: key is intentionally omitted; it is obsolete in current Compose and now only produces a warning.

Quick Reference

services:
  app:
    image: myapp:${IMAGE_TAG:-latest}      # interpolation from shell/.env
    environment:
      - TZ=${TZ:-UTC}                       # container variable with default
      - CI_SERVER_URL                       # passthrough from shell
    env_file:
      - app.env                             # bulk container variables
    build:
      args:
        - TIMEZONE=${TZ:-UTC}               # build-time only
# Resolve and inspect
docker compose config
docker compose config --format json | jq '.services.app.environment'

# Verify inside the container
docker compose exec app env | grep TZ
docker compose exec app date

See Also