You need to configure a containerized app without baking the config into the image. A database URL, an API key, a log level that's debug in staging but warn in production. Environment variables are how you do this: a process inside a container reads its environment the same way it would on a regular host, and Docker gives you several places to set those values.
The catch is that Docker has more than one mechanism, they don't all mean the same thing, and when two of them set the same variable, one silently wins. This article covers every method you'll actually use, simplest first, then the precedence rules and the traps that cost people an afternoon of debugging.
Pass a single variable with "-e"
The fastest way to set a variable is the -e flag (long form --env) on docker run. It takes a KEY=value pair:
1docker run -e LOG_LEVEL=debug -e APP_PORT=8080 myapp:latest
Both variables are now present in the container's environment, fixed at the moment the container is created (see how to start a Docker container for why config can't change after that). You can repeat -e as many times as you need. To confirm they landed, run env inside the container:
1docker run -e LOG_LEVEL=debug myapp:latest env | grep LOG_LEVEL
1LOG_LEVEL=debug
If you pass -e a name with no value, Docker copies the value from your own shell:
12export LOG_LEVEL=debugdocker run -e LOG_LEVEL myapp:latest
This is handy in continuous integration (CI), where the variable is already exported in the build environment and you don't want to hardcode it. Watch the quoting, though. In -e VAR=$OTHER, your shell expands $OTHER before Docker ever sees the argument, so the container gets the host's value, not a Docker reference.
Load many variables with "--env-file"
Once you're passing more than three or four variables, the command line gets unwieldy. Move them into a file and point docker run at it with --env-file. The file can be named anything; this example uses app.env:
1234# app.envLOG_LEVEL=debugAPP_PORT=8080DATABASE_URL=postgres://db:5432/app
Then reference it:
1docker run --env-file ./app.env myapp:latest
Each non-comment line becomes one variable. Lines starting with # are ignored, and blank lines are skipped. One thing that surprises people: docker run --env-file does not perform variable interpolation. A line like URL=http://${HOST}/api is passed through literally, dollar signs and all, because interpolation is a Docker Compose feature, not a docker run one. Quotes are also treated as part of the value in recent Docker versions, so NAME="value" gives you a value that includes the quote characters. Keep env files plain.
Set variables in Docker Compose
Most people don't run long docker run commands by hand; they use Compose. Compose gives you two directives, and the distinction between them is the single biggest source of confusion in this whole topic.
The environment directive sets variables inline, either as a map or a list:
123456services:app:image: myapp:latestenvironment:LOG_LEVEL: debugAPP_PORT: 8080
The env_file directive points at one or more files, equivalent to --env-file but defined in the Compose file:
12345services:app:image: myapp:latestenv_file:- ./app.env
You can use both on the same service. If a variable appears in both, the environment directive wins. As of Compose 2.24.0 you can also mark a file optional so a missing file doesn't error out:
12345env_file:- path: ./app.envrequired: true- path: ./app.local.envrequired: false
The ".env" file and variable substitution
Compose automatically reads a file named .env in your project directory. That file is not injected into your containers, which is exactly where the long debugging sessions come from. It feeds variable substitution inside the Compose file itself.
Say you want the image tag to be configurable. Put it in .env:
123# .envTAG=v1.5LOG_LEVEL=debug
Then reference it with ${...} substitution syntax in the Compose file:
12345services:app:image: myapp:${TAG}environment:LOG_LEVEL: ${LOG_LEVEL}
When you run docker compose up, Compose substitutes myapp:v1.5 into the config. The LOG_LEVEL value reaches the container only because you explicitly referenced it under environment. Without that line, LOG_LEVEL in .env would influence nothing inside the container. To verify what Compose actually resolved before anything starts, run:
1docker compose config
This prints the fully interpolated configuration, which is the fastest way to catch a substitution that didn't go the way you expected.
Substitution supports defaults and required checks, which are worth using:
12345environment:# use 8080 if APP_PORT is unset or emptyAPP_PORT: ${APP_PORT:-8080}# fail with a message if DATABASE_URL is missingDATABASE_URL: ${DATABASE_URL:?database url is required}
You can point Compose at a different substitution file with docker compose --env-file .env.production up, which is a clean way to switch between environments.
Build-time vs runtime: "ARG" vs "ENV"
Everything above sets variables at runtime, when the container starts. The Dockerfile has two instructions that operate at different times, and mixing them up produces variables that mysteriously aren't there.
ARG defines a build-time variable. It exists only while the image is being built and is gone by the time the container runs. You supply it with --build-arg:
12345ARG NODE_VERSION=20FROM node:${NODE_VERSION}-slimARG BUILD_ENV=productionRUN echo "Building for ${BUILD_ENV}"
1docker build --build-arg BUILD_ENV=staging -t myapp .
ENV defines a variable that gets baked into the image and is present at runtime in every container started from it:
12ENV LOG_LEVEL=infoENV APP_PORT=8080
The bridge between them is that an ENV can read an ARG, which is how you promote a build-time value into the runtime environment:
12ARG BUILD_ENV=productionENV APP_ENV=${BUILD_ENV}
Now APP_ENV is available when the container runs, carrying whatever you passed at build time. The rule of thumb: use ARG for things that affect how the image is built (base image version, feature flags for the build) and ENV for defaults the application reads at runtime. Never put secrets in either one, because both are visible in the image's build history via docker history.
When the same variable is set in multiple places
Because a variable can come from a Dockerfile ENV, a Compose env_file, a Compose environment block, and the command line all at once, Docker applies a fixed precedence. From the official Compose documentation, highest to lowest:
docker compose run -eon the CLIenvironmentorenv_filewith a value interpolated from your shell or.env- The
environmentattribute with a literal value - The
env_fileattribute - The image's
ENVdirective
The practical takeaway is that anything you set explicitly closer to runtime overrides the image's baked-in defaults, which is exactly what you want: ship sensible ENV defaults in the image, then override per environment with Compose or the CLI.
Common pitfalls
The .env-is-not-env_file confusion deserves repeating because it produces a maddening symptom: your .env file is full of variables, the app can't see any of them, and nothing errors out. The root .env is for interpolating the Compose file. If you want those values inside the container, either reference them under environment or load the file with env_file.
The second trap is treating environment variables as a secrets store. Anyone who can run docker inspect <container> sees every environment variable in plain text, and so does anything that can read /proc/<pid>/environ on the host. For database passwords and API tokens, use Docker secrets or a secret mount, which keep the value out of the image, the container config, and the process listing.
The third is forgetting that docker run --env-file skips interpolation while Compose env_file behaves slightly differently across versions. If a value with a $ in it is reaching your app mangled or unresolved, run docker compose config (for Compose) or docker exec <container> env (for a running container, the same way you'd check container logs) and look at the actual value rather than guessing.
Final thoughts
Pick the method that matches the scope: -e for one-off overrides, --env-file or env_file for grouped config, .env plus ${...} substitution for environment-specific Compose values, and ARG/ENV for the build-versus-runtime split. Keep secrets out of all of them and lean on a real secrets mechanism instead. When a value isn't what you expect, the precedence list and docker compose config will almost always explain why.
Once your containers are configured and running, the next question is what they're actually doing with that configuration in production. Dash0's infrastructure monitoring tracks container resource usage alongside real-time logs and distributed traces, so a misconfigured environment variable shows up as a behavior change you can see, not a silent failure you have to hunt for.
Start a free trial to monitor your containerized workloads in one place. No credit card required.