Docker Compose Environment Variables: Get Them Right

By Ines Duarte ·

Docker Compose Environment Variables: .env vs environment vs env_file

You copied a compose.yaml from a README, dropped a POSTGRES_PASSWORD=supersecret line into a file called .env, and ran docker compose up. Instead of a running database, you got a warning: "The 'POSTGRES_PASSWORD' variable is not set. Defaulting to a blank string." Or worse, no warning at all, just a container that starts fine and then refuses every connection because the password never made it inside.

Working with docker compose environment variables trips up almost everyone at this exact spot, and it's not because the syntax is hard. It's because Compose asks one small piece of text, KEY=value, to do two completely different jobs, and nothing in the file tells you which job is happening where. This guide separates those two jobs, then walks through the .env file, the environment attribute, and the env_file attribute, the order Compose uses when more than one sets the same variable, and a single command that shows you what Compose saw.

You'll want Docker Compose installed (version 2.24 or newer for the required: false option covered below) and a throwaway compose.yaml to experiment with. This guide assumes the modern docker compose command with a space.

Two Jobs, One Syntax: Interpolation vs the Container Environment

Think of your compose.yaml file as a mail-merge template, the kind that has "Dear [First Name]" in it before you run the merge. Filling in [First Name] happens once, before the letter gets printed. That's interpolation: Compose reads your YAML file, finds every ${VARIABLE} placeholder, and replaces it with a value before a single container starts.

The second job is what actually goes inside the envelope. That's the container environment, the set of variables a running process can see when it calls something like process.env.POSTGRES_PASSWORD in Node or os.environ["POSTGRES_PASSWORD"] in Python. This is set by the environment attribute, the env_file attribute, or a flag on the command line.

The analogy holds up except for one spot: a single .env file can feed both jobs at once, depending on how you reference it. That dual role is what causes the confusion, so let's take the file itself first. If you'd rather see the whole Compose workflow first, our guide to using Docker Compose covers the basics.

What Compose reads automatically from .env

Compose looks for a file literally named .env in your project directory, which is not always the folder you're standing in. The project directory is decided in this order: the folder passed to --project-directory if you set it, otherwise the folder containing the first file you pass with -f, otherwise your shell's current working directory. If you run docker compose up from one folder while your compose.yaml lives in another, Compose won't find the .env, and your variables silently default to empty.

Here's a project directory where the automatic .env pickup will work:

my-app/
├── compose.yaml
└── .env

And here's a sample .env file:

POSTGRES_PASSWORD=supersecret
POSTGRES_DB=app
PG_TAG=16

One thing worth saying plainly, because it's the most common wrong assumption I see in beginner questions: a .env file picked up this way only feeds interpolation. It does not, by itself, set variables inside your containers. To get those values into a container's environment, you either reference them with ${VAR} inside an environment block, or point env_file at the same file explicitly.

If you need Compose to read a differently named file for interpolation, or one that lives somewhere else, pass it explicitly:

docker compose --env-file ./config/staging.env up

${VAR} interpolation and defaults

Interpolation syntax has a few useful variants, all defined in the Compose Specification:

  • ${VAR}: substitute the value of VAR, or an empty string with a warning if it's unset.
  • ${VAR:-default}: use default if VAR is unset or empty.
  • ${VAR-default}: use default only if VAR is completely unset (an empty value is used as-is).
  • ${VAR:?message}: stop and print message if VAR is unset or empty. Handy for values you cannot run without.
  • ${VAR:+alt}: use alt only if VAR is set, otherwise nothing.
  • $$: a literal dollar sign.

For filling these placeholders, Compose checks the shell's own environment variables first, then any file passed with --env-file, then the .env file in the project directory. This order is documented on Docker's environment variable precedence page.

The blank-string warning deserves a concrete example. Say your compose.yaml has this line:

services:
  db:
    image: postgres:${PG_TAG}

If PG_TAG is unset anywhere Compose looks, the line resolves to image: postgres:, a colon with nothing after it. Docker will reject that with an error that has nothing obviously to do with a missing variable. Add a default and the same line becomes forgiving:

services:
  db:
    image: postgres:${PG_TAG:-16}

Now if PG_TAG is missing, you get Postgres 16 instead of a broken pull. The Compose Specification's interpolation reference also notes something easy to miss: interpolation only applies to values in your YAML, never to the keys, so you can't use a variable to generate a service name.

The environment attribute

The environment attribute is the most direct way to set variables inside containers. It accepts two equivalent forms, a map or a list:

services:
  api:
    image: myapp:1.4
    environment:
      NODE_ENV: production
      LOG_LEVEL: debug
      SHOW_BANNER: "true"
services:
  api:
    image: myapp:1.4
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=debug
      - SHOW_BANNER=true

Notice "true" is quoted in the map version. YAML treats an unquoted true or false as a real boolean, not text, and will silently convert it to True or False depending on the parser. Most applications expect the string "true". If your app ever behaves as though a flag is always on or always off no matter what you set, check this first.

You can also list a key with no value at all:

environment:
  - API_TOKEN

This tells Compose "pass through whatever API_TOKEN is set to in the shell that ran docker compose up." If that shell doesn't have it set, the variable is left out of the container's environment entirely rather than set to an empty string.

The env_file attribute

Where environment lists variables one by one in the YAML itself, env_file points at a file and loads everything in it, which is friendlier once you have more than three or four variables:

services:
  api:
    image: myapp:1.4
    env_file:
      - .env
      - .env.production

A few rules that aren't obvious the first time:

  • Relative paths are resolved from the folder your Compose file sits in, not from wherever you run the command.
  • Multiple files are processed top to bottom; if the same key appears in more than one, the last file wins.
  • Comments start with #, blank lines are ignored, and a line with just VAR and no = sets it to an empty string.
  • Unquoted and double-quoted values still go through interpolation inside the env file itself; single-quoted values are literal text.

Two newer options are worth knowing. required: false, added in Compose v2.24.0, lets you reference a file that might not exist without Compose refusing to start:

env_file:
  - path: ./local-overrides.env
    required: false

format: raw, added in v2.30.0, tells Compose to load the file's values exactly as written, with no interpolation and no quote-stripping, which matters when a value legitimately contains a dollar sign or a quote character:

env_file:
  - path: ./raw-secrets.env
    format: raw

One more nuance: interpolation inside a .env file is a Compose CLI feature. Plain docker run --env-file doesn't do it, so only reuse a file that way if it has no ${...} placeholders.

environment vs env_file: which one do you need

Both attributes end up putting variables inside the container, so the choice usually comes down to how many variables you have and where they need to live.

Use environment whenUse env_file when
You have a handful of variables specific to one serviceYou have many variables, or the same set shared across services
The values are safe to commit to version controlSome values shouldn't be committed (pair with .gitignore)
You want the values visible directly in compose.yaml for readabilityYou want to keep secrets and config out of the YAML file itself
You need pass-through of a host shell variable by name onlyYou're loading values generated by another tool or script

If both are set on the same service and define the same key, environment wins over env_file. That rule is stated in Docker's Compose file services reference, and it matters because it's easy to set a default in an env file, override it with environment, and forget the override is there.

Who wins: the full precedence order

When more than one mechanism tries to set the same variable inside a container, Compose follows a fixed order, strongest first:

  1. docker compose run -e VAR=value, set on the command line for a one-off run.
  2. A value set by environment or env_file that itself gets interpolated from the shell or an env file.
  3. A literal value written directly under environment in the YAML.
  4. A value loaded from a file listed under env_file.
  5. ENV set in the image's Dockerfile, which only applies if nothing above it mentions that variable.

Item two surprises almost everyone. If your shell has export LOG_LEVEL=trace set, and your compose.yaml has environment: [LOG_LEVEL] with no explicit value (pass-through), that beats a literal environment: [LOG_LEVEL=debug] written elsewhere. The precedence page linked above spells out every edge case; it's the page I keep open whenever a client's staging environment behaves differently from production (and if that staging stack is reachable from the internet, keep it out of Google while you debug).

Dockerfile ENV and ARG variables

This is where dockerfile environment variables fit in, separate from anything Compose controls directly. Inside a Dockerfile, ENV sets a variable that becomes part of the built image and persists into any container started from it, Compose or not:

FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app

ARG, by contrast, only exists during the build itself; it is not embedded in the final image. This matters for security: build arguments are visible in docker history, so an ARG is never a safe place to pass a password or API key, even temporarily. The Dockerfile reference is the authoritative source on the difference.

As the precedence list shows, a Dockerfile's ENV only takes effect if Compose has nothing to say about that variable. It's the fallback, not a starting point Compose builds on top of. If you're building the image these variables live in, dockercompose.tools' Dockerfile generator sets up a multi-stage build with sane ENV and ARG placement by default.

Debug it in one command

Guessing why a variable isn't showing up wastes time. Compose has a command that shows you exactly what it resolved, before anything runs:

  1. Run docker compose config from your project folder. You should see your full compose.yaml printed back with every ${VAR} placeholder replaced with its resolved value. If a value shows up blank or missing, the problem is at the interpolation stage.
  2. Run docker compose config --environment. This prints every variable Compose found and where it came from. If your variable doesn't appear at all, Compose never saw it, which usually means the wrong project directory or a typo in the name.
  3. Run docker compose config --variables. This lists every variable your Compose file references, along with any default set with :-. If a variable you expected doesn't show up, check for a typo in the ${...} syntax itself, like a missing closing brace.

These three flags are documented on the docker compose config reference page. If the output itself looks structurally wrong, unexpected indentation or a key in the wrong place, that's a YAML problem rather than a variable problem, and dockercompose.tools' Compose validator will catch it with a line number and an explanation.

Keeping passwords out of compose.yaml

The pattern that solves most of this for a self-hosted project: reference secrets with ${VAR} in your compose.yaml, keep the actual values in a .env file, and add that file to .gitignore.

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env file}
      POSTGRES_DB: ${POSTGRES_DB:-app}

The :? syntax is doing real work here. If POSTGRES_PASSWORD is missing, Compose refuses to start and prints your own message instead of quietly handing the database an empty password. The dockercompose.tools Compose generator builds exactly this pattern: its "Read secrets from .env" option scans your services for anything that looks like a password, token, or key, moves it to a ${VAR} reference, and prints the matching .env file underneath.

It's worth being honest about the limits. Docker's own guidance is direct: environment variables aren't meant for genuinely sensitive data, and the official recommendation is Compose's dedicated secrets: mechanism for anything that needs stronger handling than a git-ignored file. For a personal project or a small self-hosted stack, a .env file that never leaves your machine is a reasonable first step. If real users or real money are involved, move to the secrets: block and the _FILE convention that several official images, including Postgres, support directly, and review the security headers the web tier in front of it sends.

Six traps to check before you give up

  1. Wrong project directory. Your .env sits next to a compose.yaml you're not running from that location. Confirm with docker compose config --environment.
  2. The blank-string warning. A referenced variable has no default and resolves to nothing, sometimes breaking an image tag. Add a :-default or a :?message.
  3. Unquoted booleans. DEBUG: true in a YAML map becomes a real boolean, not the string your app expects. Quote it: DEBUG: "true".
  4. Dollar signs in values. A password like Pa$word gets partially eaten by interpolation. Write it as Pa$$word in the YAML, keep it in single quotes inside a .env file, or load it with format: raw.
  5. Mismatched quoting in .env. Single quotes mean "literal text, no interpolation." Double or no quotes still get ${...} substitution applied.
  6. Assuming env_file and .env are interchangeable. Naming a file .env only makes Compose read it for interpolation. Getting its values into a container still requires environment or an explicit env_file entry.

Frequently Asked Questions

How do I pass environment variables to Docker Compose?

Use the environment attribute for variables specific to one service, written as a map or a list under that service in your compose.yaml. For a one-off value on a single run, add -e VAR=value to a docker compose run command; it overrides everything else for that run only.

What is the difference between environment and env_file?

environment lists variables directly inside your compose.yaml, one by one, which is readable for a small number of values. env_file points to a separate file and loads everything inside it, which scales better once you have many variables or want to keep them out of the YAML. When both set the same key for the same service, environment wins.

Does Docker Compose read the .env file automatically?

Yes, but only for filling ${VAR} placeholders in your YAML file, and only if it sits in the project directory Compose is using (the folder set by --project-directory, or the folder of your first -f file, or your current directory). A .env file picked up this way does not automatically become part of a container's environment; you still need environment or an explicit env_file entry.

How do I keep passwords out of compose.yaml?

Reference the value with ${PASSWORD_VAR} in your compose.yaml, store the actual password in a .env file, and add that file to .gitignore. This is a reasonable baseline for self-hosted projects, though Docker's own documentation recommends the dedicated secrets: mechanism for anything with stricter security requirements.