DevToolBoxGRATIS
Blog

Docker Compose env_file vs environment: Kapan Menggunakan Mana

7 menit bacaoleh DevToolBox

Docker Compose gives you multiple ways to inject environment variables into containers: the environment directive, the env_file directive, the root-level .env file, and build-time ARG values. Each behaves differently with distinct precedence rules, security implications, and use cases. This guide breaks down exactly when to use which, with copy-paste docker-compose.yml examples for every scenario.

Quick Answer: env_file for Secrets, environment for Config

If you just need a fast rule of thumb:

  • environment: Use environment for non-sensitive, service-specific config that you want visible in your Compose file (port numbers, log levels, feature flags).
  • env_file: Use env_file for secrets and grouped variables you want to keep out of version control (API keys, database passwords, third-party credentials).
  • .env: Use the root .env file for Compose file interpolation only (image tags, port mappings, replica counts).
  • ARG: Use ARG for build-time values that should NOT exist at runtime (build tool versions, dependency flags).
# Quick reference — same service using all methods:
services:
  api:
    build:
      context: .
      args:
        NODE_VERSION: 20          # ARG: build-time only
    image: myapp:${TAG:-latest}   # .env: Compose interpolation
    env_file:
      - .env.secrets              # env_file: secrets (gitignored)
    environment:
      NODE_ENV: production        # environment: inline config
      LOG_LEVEL: info
      PORT: "3000"

environment: Inline and Map Syntax

The environment directive defines key-value pairs directly in your docker-compose.yml. These values are visible in version control and override values from env_file.

List Syntax (Array)

Uses KEY=VALUE strings in an array. Most common in tutorials and quick setups:

services:
  api:
    image: node:20-alpine
    environment:
      - NODE_ENV=production
      - DB_HOST=postgres
      - DB_PORT=5432
      - LOG_LEVEL=info
      - CACHE_TTL=3600

Map Syntax (Dictionary)

Uses YAML key-value mapping. Cleaner for large configurations and supports YAML anchors:

# Map syntax — cleaner for large configs
services:
  api:
    image: node:20-alpine
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      DB_PORT: "5432"            # Quotes needed for numeric strings
      LOG_LEVEL: info
      CACHE_TTL: "3600"

  # YAML anchors work with map syntax
  x-common-env: &common-env
    NODE_ENV: production
    LOG_LEVEL: info
    TZ: UTC

  worker:
    image: node:20-alpine
    environment:
      <<: *common-env            # Merge common variables
      QUEUE_NAME: tasks
      CONCURRENCY: "4"

Override Behavior

Values in environment always override the same key from env_file. This is by design, allowing you to set defaults in a file and override specific values inline:

# .env.shared (loaded via env_file)
DB_HOST=shared-database
DB_PORT=5432
DB_NAME=myapp
LOG_LEVEL=warn

# docker-compose.yml
services:
  api:
    image: node:20-alpine
    env_file:
      - .env.shared              # DB_HOST=shared-database, LOG_LEVEL=warn
    environment:
      DB_HOST: custom-db         # OVERRIDES env_file! DB_HOST=custom-db
      LOG_LEVEL: debug           # OVERRIDES env_file! LOG_LEVEL=debug
      # DB_PORT and DB_NAME keep their env_file values

# Final result inside container:
#   DB_HOST=custom-db        (from environment, overrode env_file)
#   DB_PORT=5432             (from env_file, not overridden)
#   DB_NAME=myapp            (from env_file, not overridden)
#   LOG_LEVEL=debug          (from environment, overrode env_file)

Shell Passthrough

List a variable name without a value to pass the host shell variable into the container:

# On your host machine:
export API_KEY=sk-abc123
export AWS_REGION=us-east-1

# docker-compose.yml
services:
  api:
    image: node:20-alpine
    environment:
      - API_KEY               # Passes host $API_KEY into container
      - AWS_REGION            # Passes host $AWS_REGION into container
      - CI                    # Empty string if $CI is not set on host
      - DEBUG                 # Empty string if $DEBUG is not set on host

env_file: External File Loading

The env_file directive loads variables from one or more external files into the container environment. Unlike environment, these files can be gitignored to keep secrets out of version control.

.env File Format Rules

# .env.api — env_file format rules

# Lines starting with # are comments
# Blank lines are ignored

# Basic KEY=VALUE (no spaces around =)
DB_HOST=postgres
DB_PORT=5432

# Values with spaces — use quotes
APP_NAME="My API Service"
GREETING='Hello World'

# No variable substitution! This is LITERAL:
# BASE_URL=${DOMAIN}        # WRONG — will be literal "${DOMAIN}"
BASE_URL=https://api.example.com

# Multi-line values (double quotes only)
RSA_KEY="-----BEGIN RSA KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA KEY-----"

# export prefix is stripped (compatible with shell source)
export SECRET_KEY=mysecretkey

# Empty values
EMPTY_VAR=
ALSO_EMPTY=''

Multiple Files and Merge Order

You can specify multiple files. Later files override earlier files for the same key:

# .env.defaults
DB_HOST=localhost
DB_PORT=5432
LOG_LEVEL=info

# .env.overrides
DB_HOST=production-db         # Overrides .env.defaults
LOG_LEVEL=error               # Overrides .env.defaults

# docker-compose.yml
services:
  api:
    image: node:20-alpine
    env_file:
      - .env.defaults           # Loaded first
      - .env.overrides          # Loaded second, overrides duplicates

# Final result:
#   DB_HOST=production-db     (from .env.overrides)
#   DB_PORT=5432              (from .env.defaults, not overridden)
#   LOG_LEVEL=error           (from .env.overrides)

Path Resolution

File paths in env_file are relative to the docker-compose.yml file location, NOT the current working directory. This is a common source of confusion:

# Project structure:
my-project/
  docker-compose.yml            # Compose file lives here
  .env.api                      # Same directory — OK
  config/
    .env.db                     # Subdirectory — relative to compose file
  ../shared/
    .env.common                 # Parent directory — also relative

# docker-compose.yml
services:
  api:
    env_file:
      - .env.api                # ./my-project/.env.api
      - config/.env.db          # ./my-project/config/.env.db
      - ../shared/.env.common   # ./shared/.env.common

# WRONG: Paths are NOT relative to where you run docker compose from!
# If you run "docker compose up" from a different directory,
# paths are still relative to the docker-compose.yml location.

Optional env_file (Compose v2.24+)

By default, a missing env_file causes an error. Use the required field to make it optional:

# docker-compose.yml
services:
  api:
    image: node:20-alpine
    env_file:
      - .env.shared                       # Required — error if missing
      - path: .env.local
        required: false                   # Optional — no error if missing
      - path: .env.secrets
        required: false                   # Optional — useful for dev

# Use case: .env.local exists only on developer machines
# for personal overrides, never committed to git.

.env File (Root Level): Auto-Loaded by Docker Compose

Docker Compose automatically reads a file named .env in the same directory as your docker-compose.yml (or the project directory). This file serves a special purpose that is frequently misunderstood.

What .env Actually Does

  • YES: The .env file provides values for ${VARIABLE} substitution inside docker-compose.yml itself.
  • NO: It does NOT automatically inject variables into containers.
  • TIP: To get .env values into containers, you must also use environment or env_file.

How .env Works (and Doesn't Work)

# .env (root level, auto-loaded)
TAG=3.2.1
EXTERNAL_PORT=8080
REPLICAS=3
DB_PASSWORD=supersecret

# docker-compose.yml
services:
  api:
    image: myapp:${TAG}                # WORKS: Interpolated to myapp:3.2.1
    ports:
      - "${EXTERNAL_PORT}:3000"        # WORKS: Interpolated to 8080:3000
    deploy:
      replicas: ${REPLICAS}            # WORKS: Interpolated to 3
    environment:
      - DB_PASSWORD                     # DOES NOT WORK the way you think!
      # This passes the HOST $DB_PASSWORD, not the .env value.
      # If host has no $DB_PASSWORD, the container gets an empty string.

      # To pass .env values into the container, do one of these:
      - DB_PASSWORD=${DB_PASSWORD}     # Option 1: Explicit interpolation
    # OR:
    # env_file:
    #   - .env                          # Option 2: Load entire .env file

Using --env-file to Override

You can point Compose to a different file for interpolation:

# Default: reads .env from project directory
docker compose up

# Use a different file for Compose interpolation:
docker compose --env-file .env.staging up

# Multiple env files (Compose v2.24+):
docker compose --env-file .env --env-file .env.local up

# This affects ONLY ${} interpolation in docker-compose.yml.
# It does NOT affect env_file directives or container variables.

Comparison Table: env_file vs environment vs .env vs ARG

Here is a side-by-side comparison of all four methods:

Featureenvironmentenv_file.env (root)ARG (build)
PurposeDirect container env varsLoad env vars from file(s)Compose file interpolationBuild-time variables
ScopeRuntime (container)Runtime (container)Compose file onlyBuild only (unless passed to ENV)
In docker-compose.yml?YesNoNoYes
Gitignore-able?NoYesYesNo
Good for secrets?NoPartialNoNo
PrecedenceHighestMediumLowest (interpolation only)Build-time only
Supports ${} syntax?In values onlyNot inside the file itselfN/AN/A

Variable Substitution: ${VAR:-default}, ${VAR:?error}

Docker Compose supports shell-style variable substitution with powerful default and error-handling syntax. These work in docker-compose.yml values using variables from .env or the host shell.

SyntaxResultExample
${VAR}Value of VAR, empty string if unsetimage: myapp:${TAG}
${VAR:-default}Value of VAR, or "default" if unset/emptyimage: myapp:${TAG:-latest}
${VAR-default}Value of VAR, or "default" if unset (allows empty)LOG_LEVEL: ${LOG:-}
${VAR:?error msg}Value of VAR, or EXIT with error if unset/emptyDB_HOST: ${DB_HOST:?Must set DB_HOST}
${VAR?error msg}Value of VAR, or EXIT with error if unset (allows empty)API_KEY: ${API_KEY?Missing API_KEY}
${VAR:+replacement}"replacement" if VAR is set and non-empty, else emptyDEBUG: ${DEBUG:+true}

Practical Example with Defaults and Validation

# .env
TAG=3.2.1
# DB_HOST is intentionally NOT set

# docker-compose.yml
services:
  api:
    image: myapp:${TAG:-latest}                         # -> myapp:3.2.1
    environment:
      DB_HOST: ${DB_HOST:?Error: DB_HOST is required}   # -> exits with error!
      DB_PORT: ${DB_PORT:-5432}                          # -> 5432 (default)
      LOG_LEVEL: ${LOG_LEVEL:-info}                      # -> info (default)
      DEBUG: ${DEBUG:+true}                              # -> empty (DEBUG not set)
      REPLICAS: ${REPLICAS:-1}                           # -> 1 (default)

  db:
    image: postgres:${PG_VERSION:-16}                    # -> postgres:16 (default)
    environment:
      POSTGRES_DB: ${DB_NAME:-myapp}                     # -> myapp (default)
      POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}  # -> exits with error!

Priority Order: environment > env_file > .env > Dockerfile ENV

When the same variable is defined in multiple places, Docker Compose applies a strict precedence order. Understanding this prevents hours of debugging.

From highest to lowest priority:

  1. 1. docker compose run -e KEY=VALUE (CLI override)
  2. 2. Host shell environment variables
  3. 3. environment directive in docker-compose.yml
  4. 4. env_file directive (later files override earlier files)
  5. 5. .env file (for Compose interpolation only)
  6. 6. Dockerfile ENV instruction

Priority Demonstration

# Dockerfile
ENV DB_HOST=dockerfile-db          # Priority 6 (lowest)

# .env (root)
DB_HOST=dotenv-db                  # Priority 5 (Compose interpolation only)

# .env.file (loaded via env_file)
DB_HOST=envfile-db                 # Priority 4

# docker-compose.yml
services:
  api:
    build: .
    env_file:
      - .env.file                  # DB_HOST=envfile-db
    environment:
      DB_HOST: compose-db          # Priority 3 — WINS over env_file

# On host shell:
export DB_HOST=shell-db            # Priority 2 — WINS over environment

# CLI override:
docker compose run -e DB_HOST=cli-db api   # Priority 1 — WINS over everything

# Result with all defined:
#   docker compose run -e  ->  DB_HOST=cli-db
#   Without CLI override   ->  DB_HOST=shell-db
#   Without shell export   ->  DB_HOST=compose-db
#   Without environment:   ->  DB_HOST=envfile-db
#   Without env_file:      ->  DB_HOST=dockerfile-db

Real-World Setup: Dev, Staging, Production

Here is a complete, production-tested multi-environment setup that uses all the techniques above.

Project Structure

my-project/
  docker-compose.yml              # Base config (shared across environments)
  docker-compose.override.yml     # Dev overrides (auto-loaded)
  docker-compose.staging.yml      # Staging overrides
  docker-compose.prod.yml         # Production overrides
  .env                            # Dev defaults (Compose interpolation)
  .env.staging                    # Staging defaults
  .env.prod                       # Production defaults
  .env.secrets                    # Secrets (gitignored)
  .env.secrets.example            # Template for secrets (committed)
  .gitignore                      # Ignores .env.secrets, .env.local, etc.

Base docker-compose.yml

# docker-compose.yml — shared base config
services:
  api:
    build:
      context: .
      args:
        NODE_VERSION: ${NODE_VERSION:-20}
    image: myapp:${TAG:-latest}
    ports:
      - "${API_PORT:-3000}:3000"
    env_file:
      - path: .env.secrets
        required: false
    environment:
      NODE_ENV: ${NODE_ENV:-development}
      DB_HOST: ${DB_HOST:-postgres}
      DB_PORT: ${DB_PORT:-5432}
      DB_NAME: ${DB_NAME:-myapp}
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  postgres:
    image: postgres:${PG_VERSION:-16}
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${DB_NAME:-myapp}
      POSTGRES_USER: ${DB_USER:-myapp}
    env_file:
      - path: .env.secrets
        required: false
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myapp}"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

Development: docker-compose.override.yml

This file is auto-loaded by docker compose up in development:

# docker-compose.override.yml — auto-loaded in dev
services:
  api:
    build:
      target: development
    volumes:
      - .:/app                          # Hot reload
      - /app/node_modules
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
      DEBUG: "true"
    ports:
      - "9229:9229"                     # Node.js debugger

  postgres:
    ports:
      - "5432:5432"                     # Expose DB for local tools
    environment:
      POSTGRES_PASSWORD: devpassword    # OK for dev only!

# .env (dev defaults)
TAG=latest
API_PORT=3000
NODE_ENV=development
DB_HOST=postgres
DB_NAME=myapp_dev

Staging: docker-compose.staging.yml

# docker-compose.staging.yml
services:
  api:
    build:
      target: production
    environment:
      NODE_ENV: staging
      LOG_LEVEL: info
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 512M
          cpus: "0.5"

  postgres:
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

# .env.staging
TAG=3.2.1-rc.1
API_PORT=3000
NODE_ENV=staging
DB_NAME=myapp_staging

Production: docker-compose.prod.yml

# docker-compose.prod.yml
services:
  api:
    build:
      target: production
    restart: unless-stopped
    environment:
      NODE_ENV: production
      LOG_LEVEL: warn
    deploy:
      replicas: 4
      resources:
        limits:
          memory: 1G
          cpus: "1.0"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  postgres:
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    deploy:
      resources:
        limits:
          memory: 2G

  redis:
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}

secrets:
  db_password:
    file: ./secrets/db_password.txt

# .env.prod
TAG=3.2.1
API_PORT=3000
NODE_ENV=production
DB_NAME=myapp_prod

Commands per Environment

# Development (auto-loads docker-compose.override.yml)
docker compose up

# Staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml \
  --env-file .env.staging up -d

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.prod up -d

# Verify resolved config before deploying:
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.prod config

Security Best Practices

Environment variables are inherently insecure for sensitive data. Follow these practices to minimize risk.

1. Always .gitignore Your .env Files

# .gitignore
.env.secrets
.env.local
.env.*.local
secrets/
*.pem
*.key

2. Commit a Template, Not the Real File

# .env.secrets.example (committed to git)
# Copy this file to .env.secrets and fill in real values
DB_PASSWORD=
API_KEY=
JWT_SECRET=
SMTP_PASSWORD=
AWS_SECRET_ACCESS_KEY=

# .env.secrets (gitignored — never committed)
DB_PASSWORD=RealSuperSecretP@ssw0rd
API_KEY=sk-prod-abc123xyz789
JWT_SECRET=your-256-bit-secret-here
SMTP_PASSWORD=sendgrid-api-key
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCY

3. Use Docker Secrets for Production

Docker secrets mount sensitive data as files at /run/secrets/ instead of exposing them as environment variables:

# docker-compose.yml with Docker secrets
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
    environment:
      # App reads secrets from files instead of env vars
      DB_PASSWORD_FILE: /run/secrets/db_password
      API_KEY_FILE: /run/secrets/api_key

  postgres:
    image: postgres:16
    secrets:
      - db_password
    environment:
      # PostgreSQL natively supports _FILE suffix
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt

4. Be Aware of docker inspect

Environment variables are visible via docker inspect. Anyone with Docker access can read them:

# Anyone with Docker access can see ALL environment variables:
docker inspect myapp-api-1 --format '{{range .Config.Env}}{{println .}}{{end}}'

# Output includes secrets if passed via environment:
# DB_PASSWORD=RealSuperSecretP@ssw0rd    <-- EXPOSED!
# API_KEY=sk-prod-abc123xyz789           <-- EXPOSED!

# Docker secrets are NOT visible via inspect.
# They only exist as files inside the container at /run/secrets/

5. Rotate Secrets per Environment

Never use the same passwords, API keys, or tokens across dev, staging, and production. Generate unique values for each.

# Generate unique secrets per environment:
# Dev
openssl rand -base64 32 > secrets/dev/db_password.txt
openssl rand -hex 32 > secrets/dev/jwt_secret.txt

# Staging
openssl rand -base64 32 > secrets/staging/db_password.txt
openssl rand -hex 32 > secrets/staging/jwt_secret.txt

# Production
openssl rand -base64 32 > secrets/prod/db_password.txt
openssl rand -hex 32 > secrets/prod/jwt_secret.txt

Debugging: docker compose config, docker inspect

When environment variables are not working as expected, use these systematic debugging steps.

Step 1: See the Resolved Compose File

docker compose config shows the fully interpolated Compose file with all variables resolved:

# See the fully resolved docker-compose.yml:
docker compose config

# With a specific env file:
docker compose --env-file .env.prod config

# Show only specific service:
docker compose config --services
docker compose config api

# Output shows all variables with their final values,
# so you can verify interpolation worked correctly.

Step 2: Check Container Environment

See all environment variables actually set inside a running container:

# List all env vars in a running container:
docker compose exec api env

# Search for a specific variable:
docker compose exec api env | grep DB_HOST

# Or using printenv:
docker compose exec api printenv DB_HOST

Step 3: Inspect a Stopped Container

Check environment variables on a container that has exited:

# Inspect a stopped/exited container:
docker inspect myapp-api-1 --format '{{range .Config.Env}}{{println .}}{{end}}'

# Or as JSON:
docker inspect myapp-api-1 --format '{{json .Config.Env}}' | jq .

Step 4: Verify .env File Location

The .env file must be in the project directory (where docker-compose.yml is). Verify:

# Check that .env exists in the right directory:
ls -la .env

# Check Compose project directory:
docker compose ls

# Explicitly specify the env file:
docker compose --env-file ./my-custom.env config

Step 5: Check for Overrides

Host shell variables override .env values. Check what your shell has set:

# Check host shell for conflicting variables:
echo $DB_HOST
echo $NODE_ENV
printenv | grep -i db_

# Unset a conflicting variable:
unset DB_HOST

# Then re-run:
docker compose config

Debug Command Cheatsheet

CommandPurpose
docker compose configShow resolved Compose file with all variables
docker compose config --format jsonResolved config as JSON (pipe to jq)
docker compose exec <svc> envList env vars inside running container
docker compose exec <svc> printenv VARGet specific variable value
docker inspect <container> --format '{{.Config.Env}}'Env vars of any container (running or stopped)
docker compose --env-file FILE configTest with a specific .env file
docker compose logs <svc> 2>&1 | headCheck for startup errors related to missing vars

Frequently Asked Questions

What is the difference between env_file and .env in Docker Compose?

env_file is a directive that loads variables directly into the container environment from an external file. The .env file (root level) is automatically read by Docker Compose for variable substitution inside docker-compose.yml itself, but does NOT inject variables into containers. They serve different purposes: env_file is for container configuration, .env is for Compose file templating.

Can I use both environment and env_file for the same service?

Yes, and this is a common pattern. Variables from env_file are loaded first, then environment values override any duplicate keys. This lets you keep defaults in a file and override specific values inline. For example, set shared config in .env.shared via env_file, then override DB_HOST in environment for a specific service.

Why is my .env variable not reaching the container?

The root .env file is only used for ${} interpolation in docker-compose.yml. It does NOT automatically set variables inside containers. To pass .env values into a container, you must either: (1) use env_file to point to the .env file explicitly, or (2) use environment with ${VAR} syntax to interpolate the value. This is the #1 source of confusion with Docker Compose environment variables.

How do I handle secrets in Docker Compose without environment variables?

Use Docker secrets. Define secrets in your docker-compose.yml pointing to local files, then grant services access. Secrets are mounted as files at /run/secrets/<name> inside the container. Many official images support the _FILE suffix convention (e.g., POSTGRES_PASSWORD_FILE=/run/secrets/db_password). This prevents secrets from appearing in docker inspect, process listings, or logs.

Does env_file support variable substitution like ${VAR}?

No. Unlike docker-compose.yml, files loaded via env_file do NOT support ${} variable substitution. Each line is treated as a literal KEY=VALUE pair. If you need variable expansion, define the variables in the environment directive using ${VAR} syntax, which will be interpolated from .env or host shell variables.

𝕏 Twitterin LinkedIn
Apakah ini membantu?

Tetap Update

Dapatkan tips dev mingguan dan tool baru.

Tanpa spam. Berhenti kapan saja.

Coba Alat Terkait

\Escape / Unescape🐳Docker Compose GeneratorYMLYAML Validator & Formatter

Artikel Terkait

Docker Compose Cheat Sheet: Services, Volumes, dan Networks

Referensi Docker Compose: definisi service, volume, network, environment variable, dan contoh stack.

Docker Compose Secrets & Environment Variables: Cara yang Benar

Kuasai environment variables di Docker Compose.