Environment variables in Docker Compose trip people up because the same term covers two different jobs. One is filling in values inside the Compose file — image tags, ports, paths. The other is setting variables inside the running container — database passwords, app config. They look similar, they sometimes share a .env file, and confusing them is why a variable shows up empty in your container even though it’s right there in your file.
This guide separates the two clearly. You’ll learn how variable substitution works with the .env file, how to set real environment variables inside containers with environment and env_file, which one wins when they collide, and how to keep secrets out of version control.
By the end, “why is this variable empty?” won’t be a mystery.
The two jobs environment variables do
Hold these apart in your head and everything else follows:
- Substitution (interpolation): Compose replaces
${VAR}placeholders in the Compose file itself before anything starts. The values come from your shell or a.envfile in the project directory. This is for configuring the file — tags, ports, volume names. - Container environment: Variables set inside a running container via the
environment:orenv_file:keys. This is what the application reads at runtime.
The big misunderstanding: the .env file does not automatically become the environment inside your containers. It only powers substitution in the Compose file. If you want a value inside a container, you have to put it there explicitly.
Variable substitution with the .env file
When you run a Compose command, Compose looks for a file literally named .env in the directory you’re in and uses it to fill any ${VAR} references in your Compose file.
NGINX_TAG=1.27
HOST_PORT=8080
services:
web:
image: nginx:${NGINX_TAG}
ports:
- "${HOST_PORT}:80"
Before starting anything, Compose substitutes those placeholders, so image: becomes nginx:1.27 and the port mapping becomes "8080:80". Change the values in .env, run docker compose up -d again, and the file reconfigures itself without you editing the YAML.
You can provide defaults inline, which is handy when a variable might be missing:
image: nginx:${NGINX_TAG:-1.27} # use 1.27 if NGINX_TAG is unset or empty
ports:
- "${HOST_PORT:-8080}:80"
To confirm what Compose actually resolved, ask it:
docker compose config
That prints the fully merged file with every ${VAR} replaced. It’s the fastest way to catch a placeholder that didn’t get filled.
Setting variables inside the container
To give the application running in a container its configuration, use the environment: key. This is where database credentials and app settings go:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: change-me
POSTGRES_DB: appdb
Those three variables land inside the container, and the official Postgres image reads them on first start. You can also combine substitution with the container environment — pull a value from .env and pass it into the container:
POSTGRES_PASSWORD=a-real-secret
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Now the secret lives in .env (which you keep out of git) and gets injected into the container through environment:. This pattern — secret in .env, referenced explicitly under environment: — is the clean way to do it.
environment vs env_file
When you have more than a handful of variables, listing them all under environment: gets noisy. env_file: loads them from a file instead:
APP_DEBUG=false
APP_TIMEZONE=UTC
DB_HOST=db
DB_NAME=appdb
services:
app:
image: my-app:1.0
env_file:
- app.env
environment:
APP_DEBUG: "true" # overrides the value from app.env
Every KEY=value line in app.env becomes an environment variable inside the container. Note the precedence: if the same key appears in both places, the inline environment: value wins. That makes environment: a clean way to override one or two values from a shared env_file.
Which mechanism does what
| Mechanism | Purpose |
|---|---|
| <code>.env</code> (project dir) | Substitute ${VARS} in the Compose file |
| <code>environment:</code> | Set variables inline for a service |
| <code>env_file:</code> | Load many variables from a file into a service |
| <code>--env-file</code> flag | Pick a different file for substitution |
Don’t confuse the project-level .env (substitution) with a file you pass to env_file: (container variables). They’re different files doing different jobs, even if both end in .env.
Keeping secrets out of git
A .env file with real passwords should never be committed. The standard setup:
# Add the real secrets file to .gitignore
echo ".env" >> .gitignore
# Commit a template so others know which variables exist
cp .env .env.example
# then edit .env.example to replace real values with placeholders
NGINX_TAG=1.27
HOST_PORT=8080
POSTGRES_PASSWORD=replace-me
Commit .env.example, ignore .env. Anyone cloning the project copies the example to .env and fills in real values. This keeps the shape of your config in version control without leaking the secrets.
Environment variable setup checklist
- .env in the project directory handles ${VAR} substitution in the Compose file
- Container config goes under environment: or env_file:, not the project .env alone
- Ran docker compose config to confirm values resolved correctly
- .env is listed in .gitignore
- A .env.example with placeholders is committed for others
- Production secrets use Docker secrets, not plain env vars
A quick mental model
If a value belongs in the Compose file — an image tag, a host port, a volume name — it comes from substitution and lives in the project .env. If a value belongs to the application inside the container — a password, a feature flag, a connection string — it goes under environment: or env_file:. Asking “is this configuring the file or the program?” answers which mechanism to use almost every time.
This also explains the precedence order, which is worth knowing when values seem to come from nowhere:
Precedence for a container variable (highest wins)
| 1. Compose file environment: | An inline value here beats everything below it. |
|---|---|
| 2. Shell environment | A variable set in your shell when you run docker compose. |
| 3. env_file: entries | Values loaded from the file(s) you list. |
| 4. Image default (ENV) | A default baked into the image with ENV in its Dockerfile. |
So if a container variable isn’t what you expect, walk down that list. An inline environment: value is the most common reason an env_file setting appears to be “ignored” — it isn’t ignored, it’s overridden.
Putting it together
A realistic small project ends up using both paths at once: a .env file in the project directory to pick image tags and host ports, and environment: (or env_file:) entries to push real configuration and secrets into the containers. They cooperate, but they’re not the same thing — and remembering that is what stops the “my variable is empty” confusion before it starts.
If you’re still getting your footing with Compose files in general, the Docker Compose beginner guide covers the everyday commands, and the line-by-line Compose file walkthrough explains every key you’ll meet. With substitution and container environment sorted, your Compose files get shorter, safer, and far easier to move between machines.