Every Docker Compose user hits the wall eventually. You define
POSTGRES_PASSWORD=mydbpass in an .env file, but the container
reads an empty string. You try ${VAR:-default} syntax and it
doesn’t behave the way the documentation suggested. You switch
between environments and miss a variable, causing cryptic startup
failures.
The .env file in Docker Compose looks simple on the surface, but
the scoping rules, substitution behavior, and interaction with
environment, env_file, and Docker secrets have subtle traps
that trip up everyone — including experienced operators.
This guide covers everything you need to manage environment
variables in Docker Compose: how .env files work, when variables
are substituted and when they aren’t, the difference between
environment and env_file, and the security boundaries between
env vars and Docker secrets. Every example uses real
docker-compose.yml snippets you can adapt to your homelab.
How .env Files Work in Docker Compose
Docker Compose reads the .env file from the project directory
(the directory containing your docker-compose.yml). Variables
defined there are interpolated into the compose file before
services are built or started.
|
|
|
|
|
|
When you run docker compose up -d, Compose reads .env,
substitutes ${NGINX_VERSION}, ${HTTP_PORT}, and ${DOMAIN},
then creates the container with nginx:1.27-alpine, port 8080,
and NGINX_HOST=web.gntech.dev.
Where Docker Compose Looks for .env
The .env file must be in the same directory as the compose
file or in the project directory (the parent of the compose
file if you use -f). Compose does not search parent
directories recursively.
|
|
|
|
Shell Variables vs .env Precedence
When a variable exists in both the shell environment and the
.env file, the shell environment wins. This is by design —
it lets you override .env values without editing the file:
|
|
Variable resolution order (highest to lowest priority):
- Shell environment variables
.envfile variables- Compose file hardcoded values
- Variable default (
:-) or error (:?) fallback
This means you can chain overrides safely. The compose file
defines the structure, .env stores the defaults for your
environment, and shell vars handle temporary overrides.
Variable Substitution Syntax
Docker Compose supports the full set of shell-like variable
substitution expressions. These work in any string value
in docker-compose.yml — image tags, port numbers, volume paths,
environment values, labels, and extension fields.
Basic Substitution
|
|
If TAG is undefined, Compose raises an error and refuses to
start. Always provide a default or explicitly handle missing
variables.
Default Values with :-
|
|
If TAG is not set, Compose substitutes latest. If PORT is
not set, it substitutes 3000. This is the safest pattern for
optional configuration.
Mandatory Variables with :?
|
|
When :? is used and the variable is unset or empty, Compose
prints the error message and exits before creating any
containers. This catches missing configuration at the parsing
stage instead of at container runtime.
Alternate Value with :+
|
|
If VERBOSE is set (to any non-empty value), LOG_LEVEL becomes
debug. If VERBOSE is unset, LOG_LEVEL is an empty string.
Use this for toggle-style configuration.
Escape $ for Literal Dollar Signs
Docker Compose interprets $ as the start of a variable. To
use a literal $ in a value, double it:
|
|
The $$ produces a single $ at runtime. The backslash-escape
\$ is recognized by Docker Compose 2.x as an alternative.
environment vs env_file — When to Use Each
Docker Compose provides two ways to pass variables into a container. They look similar but behave differently.
The environment Block
Variables in environment are always passed to the
container, regardless of how they’re defined:
|
|
The environment block is interpolated at compose parsing
time — ${DB_HOST} is resolved before the container starts.
If DB_HOST is not set anywhere, the default postgres is used.
The env_file Directive
|
|
|
|
Variables from env_file are not interpolated by Compose.
They are passed verbatim to the container. If app.env contains
DB_HOST=${DB_HOST:-postgres}, the container sees the literal
string ${DB_HOST:-postgres}, not the resolved value.
This is the most common source of confusion. Use env_file
for static key-value pairs that don’t need interpolation. Use
environment when you need variable substitution.
Which One to Use
| Situation | Use |
|---|---|
| Static config that never changes | environment (hardcoded) |
| Values that differ per environment | .env + environment (interpolation) |
| Long lists of variables (10+) | env_file |
| Secrets or credentials | Docker secrets (see below) |
| Third-party config (Terraform output) | env_file generated by script |
A practical hybrid approach for a homelab stack:
|
|
|
|
|
|
Multiple .env Files and Override Chains
Compose 2.x supports loading multiple .env files with the
--env-file flag:
|
|
Later files take precedence. Variables from the shell
environment always win over any .env file.
Environment-Specific Compose Files
For more complex setups, combine .env with multiple compose
files and the -f flag:
|
|
|
|
The production override file adds restart policies and resource
limits without modifying the base compose file. The .env.prod
file supplies production-specific variables (database URLs, API
keys, domain names).
Project-Level .env with Multiple Stacks
If you run multiple compose stacks in subdirectories, create a
.env in each project directory:
|
|
If you want a shared .env at the top level, load it explicitly:
|
|
Extension Fields for Dry Variable Reuse
Compose extension fields (x-*) let you define reusable
variable blocks and interpolate them across services:
|
|
All services inherit the same logging config and restart policy
from the extension fields. Change LOG_MAX_SIZE or MEM_LIMIT
in .env and all services pick it up.
Environment Variables vs Docker Secrets
Environment variables are not secrets. Any process that can
run docker inspect on a container can read its environment
variables. Any process inside the container can read
/proc/1/environ. Compose files (and their .env files) are
often committed to version control.
Docker secrets are the correct mechanism for sensitive data:
|
|
Secrets are mounted as tmpfs files at /run/secrets/<name>
inside the container. They never appear in docker inspect
output, command lines, or environment dumps.
When to Use Each
Use environment variables for:
- Service names and ports
- Runtime mode (development vs production)
- Feature flags and toggles
- Log levels and debug settings
- Database names, usernames (but not passwords)
Use Docker secrets for:
- Database passwords and API tokens
- TLS private keys and certificates
- Any value that would cause damage if leaked
- Credentials for external services (S3, SMTP, Cloudflare)
Hybrid Pattern for Legacy Images
Some Docker images only accept credentials through environment
variables (e.g., POSTGRES_PASSWORD). For these, use the
_FILE convention:
|
|
Postgres detects POSTGRES_PASSWORD_FILE and reads the secret
file instead of the environment variable. Many official images
support this pattern (*_FILE). Check the image documentation.
Putting It All Together — Real Homelab Compose
Here is a complete example combining every pattern from this post:
|
|
|
|
|
|
This stack uses every pattern:
.envfor environment-specific values- Extension fields for shared logging and healthcheck config
:?mandatory variable validation for database config:-defaults for ports, log sizes, and tags- Docker secrets for database password and Cloudflare API token
*_FILEconvention for images that only accept env varsenv_filefor large static config blocks
Debugging Variable Substitution
When variables don’t resolve the way you expect:
|
|
|
|
Use docker compose config as your primary debugging tool. If
it shows the wrong value, trace the resolution chain: shell
env → .env → compose file defaults.
Summary
Docker Compose variable management has clear rules once you know them:
.envis auto-loaded from the compose file directory; shell vars override it:-provides defaults,:?enforces required variables,:+enables toggle patternsenvironmentis interpolated at parse time;env_fileis passed verbatim to the container- Use
--env-filefor loading.envfrom custom paths or chaining multiple files - Extension fields (
x-*) let you define reusable variable-driven config blocks - Docker secrets for credentials;
_FILEconvention for images that only support env vars docker compose configshows the fully resolved config for debugging
Start every new compose stack with a .env file for
environment-specific values, :? for mandatory variables,
Docker secrets for credentials, and extension fields for shared
config. Your future self — and anyone else deploying your stack —
will thank you.