Docker Compose Env: A Complete Guide to Managing Environment Variables
Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.
If you’ve spent any time building multi-container applications, you already know that hardcoding configuration values into your docker-compose.yml is a fast track to pain. Passwords end up in git history, staging and production drift apart, and every new team member has to guess which values actually matter. Getting docker compose env handling right — cleanly, securely, and predictably — is one of the highest-leverage things you can do for a Compose-based stack.
This guide covers every practical way Compose lets you inject environment variables into containers: the environment key, .env files, the env_file directive, shell interpolation, and the precedence rules that decide which value wins when they collide. We’ll also cover common mistakes, security considerations, and how this fits into a broader Docker Compose networking guide if you’re building out a full stack.
Why Environment Variables Matter in Compose
Environment variables are the standard way to configure containerized applications without baking configuration into the image itself. This follows the twelve-factor app methodology, which recommends storing config in the environment rather than in code. Docker Compose gives you several mechanisms to do this, and understanding when to use each one saves you from a lot of “why isn’t this variable set” debugging sessions.
The environment Key
The most direct way to set variables is the environment key inside a service definition:
services:
web:
image: myapp:latest
environment:
- NODE_ENV=production
- API_PORT=3000
- DEBUG=false
You can also write it as a map instead of a list:
services:
web:
image: myapp:latest
environment:
NODE_ENV: production
API_PORT: 3000
DEBUG: "false"
Both forms are equivalent. The map form is often easier to read in larger files, and it avoids quoting ambiguity around booleans and numbers since YAML will coerce unquoted values.
You can also pass through variables from your shell without hardcoding a value:
services:
web:
environment:
- API_KEY
If API_KEY is exported in your shell, Compose passes that value straight into the container. This is useful for secrets you don’t want sitting in the compose file at all.
Using .env Files for Variable Substitution
Compose automatically reads a file named .env in the same directory as your docker-compose.yml and uses it for variable interpolation inside the compose file itself — not for injecting variables directly into containers. That distinction trips up a lot of people.
A typical .env file looks like this:
POSTGRES_USER=appuser
POSTGRES_PASSWORD=supersecret
POSTGRES_DB=appdb
APP_PORT=8080
You reference these values inside docker-compose.yml using ${VARIABLE} syntax:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${APP_PORT}:5432"
This lets you keep one compose file and swap out .env contents per environment (dev, staging, prod) without touching YAML. You can verify how Compose resolves these values before actually starting containers:
docker compose config
This command prints the fully resolved configuration, with every ${VAR} substitution applied — extremely useful for debugging why a container isn’t getting the value you expect.
The env_file Directive
Where .env handles substitution in the compose file, env_file loads variables directly into a specific service’s container environment:
services:
api:
image: myapp:latest
env_file:
- .env.api
- .env.secrets
This is the cleanest option when you have many variables, since you avoid listing them one by one under environment. It also lets different services load entirely different variable sets, which environment alone can’t do as elegantly. A typical .env.api might contain:
LOG_LEVEL=info
CACHE_TTL=300
FEATURE_FLAG_NEW_UI=true
Keep in mind that values loaded via env_file are not available for ${} interpolation elsewhere in the compose file — only the root .env file gets that treatment.
Precedence: Which Value Actually Wins?
When the same variable is defined in multiple places, Compose applies a strict precedence order, from highest to lowest priority:
docker compose run -e VAR=value on the command lineenvironment key in the serviceenv_fileENV in the Dockerfile)docker compose, when referenced via ${VAR}In practice this means environment always overrides env_file, so if you’re debugging a mysterious value that won’t change, check whether it’s hardcoded under environment somewhere overriding your .env.api file.
Overriding Variables per Environment with Multiple Compose Files
A common pattern for managing dev vs. production differences is layering compose files:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Your base file defines shared services, and docker-compose.prod.yml overrides just the pieces that differ:
services:
web:
environment:
NODE_ENV: production
env_file:
- .env.production
This avoids maintaining two nearly identical full compose files and keeps environment-specific values isolated. For a deeper look at structuring multi-file Compose projects, see our guide to Docker Compose networking, which covers how these override patterns interact with custom networks.
Common Mistakes to Avoid
A handful of mistakes account for most of the “my env vars aren’t working” support requests:
.env to version control. If it contains secrets, it belongs in .gitignore, with a .env.example template committed instead..env variables reach the container automatically. They only drive interpolation in the compose file — use environment or env_file to actually inject them..env files. Compose does not process shell-style quoting the way bash does; VALUE="hello world" will include the literal quote characters. Leave values unquoted unless you specifically need them..env. Compose doesn’t hot-reload environment values — you need docker compose up -d --force-recreate or at least a restart of the affected service.ARG in a Dockerfile is only available during the image build; it has nothing to do with the runtime environment block unless you explicitly pass it through with ENV.Debugging Environment Variables Inside a Running Container
Sometimes the fastest way to confirm what a container actually received is to just ask it. docker compose exec lets you run commands inside a running service:
docker compose exec web printenv
This prints every environment variable Compose actually passed to that container’s process — after all .env interpolation, environment overrides, and env_file merging have already happened. If a specific variable is missing, grep for it directly:
docker compose exec web printenv | grep API_KEY
For a container that crashes before you can exec into it, add a temporary sleep or check the logs with docker compose logs web to see if the application logged a missing-variable error on startup. Combining this with docker compose config — which shows what Compose resolved before the container even starts — usually narrows the problem down to either a compose-level substitution issue or an application-level parsing issue.
Security Considerations
Environment variables are convenient, but they’re not a secure secrets store. Anything set via environment or env_file is visible to anyone who can run docker inspect or exec into the container and read /proc/1/environ. For genuinely sensitive values — API keys, database passwords, TLS private keys — consider Docker secrets in Swarm mode, or a dedicated secrets manager, rather than relying purely on plaintext .env files.
If you’re self-hosting your Compose stack, the underlying infrastructure matters just as much as the compose configuration. Running your containers on a provider like DigitalOcean gives you predictable, isolated droplets where you control firewall rules around which ports and services are actually exposed — critical when a misconfigured environment block accidentally binds something to 0.0.0.0. And once your stack is live, pairing it with uptime and log monitoring through a service like BetterStack means you’ll catch a broken deployment (say, a service that silently fails to start because a required env var is missing) within minutes instead of discovering it from a support ticket.
If you’re new to Compose generally, our beginner’s guide to environment variables in Linux is a good primer on how shell-level exports interact with anything Compose eventually passes to a container.
Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).
FAQ
Q: Does Compose automatically load .env from any directory?
A: No. Compose only reads a .env file located in the same directory as the docker-compose.yml file you’re running, or the directory you specify with --project-directory. It does not search parent directories.
Q: Can I use a different filename instead of .env?
A: Yes, with docker compose --env-file .env.staging up -d. This only changes which file Compose uses for interpolation — it doesn’t affect env_file directives inside services, which you set explicitly per service.
Q: Why does my variable show up as an empty string instead of failing?
A: Compose treats an undefined variable referenced via ${VAR} as an empty string by default rather than erroring. Use ${VAR:?error message} syntax to make Compose fail loudly if a required variable is missing.
Q: How do I check what values Compose is actually resolving?
A: Run docker compose config to print the fully rendered configuration after all substitutions are applied. This is the fastest way to debug a misbehaving variable.
Q: Should secrets go in environment or env_file?
A: Neither is truly secure — both end up as plaintext inside the container’s environment. For production secrets, prefer Docker secrets, a vault service, or your cloud provider’s secret manager, and reserve environment/env_file for non-sensitive configuration.
Q: Can I set default values if a variable isn’t defined?
A: Yes. Use ${VAR:-default} syntax in your compose file, e.g. ${APP_PORT:-8080}, which falls back to 8080 if APP_PORT is unset or empty.
Wrapping Up
Getting comfortable with the interplay between environment, .env, and env_file removes an entire category of “works on my machine” bugs from your Compose workflow. Start with docker compose config any time behavior looks wrong, keep secrets out of files that touch version control, and layer compose files rather than duplicating them for environment-specific overrides. Once your configuration strategy is solid, the rest of your stack — networking, volumes, scaling — gets a lot easier to reason about.
Leave a Reply