Dash0 Raises $110M Series B at $1B Valuation

  • 11 min read

How to Pass Environment Variables to a Docker Container

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:

bash
1
docker 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:

bash
1
docker run -e LOG_LEVEL=debug myapp:latest env | grep LOG_LEVEL
text
1
LOG_LEVEL=debug

If you pass -e a name with no value, Docker copies the value from your own shell:

bash
12
export LOG_LEVEL=debug
docker 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:

text
1234
# app.env
LOG_LEVEL=debug
APP_PORT=8080
DATABASE_URL=postgres://db:5432/app

Then reference it:

bash
1
docker 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:

yaml
123456
services:
app:
image: myapp:latest
environment:
LOG_LEVEL: debug
APP_PORT: 8080

The env_file directive points at one or more files, equivalent to --env-file but defined in the Compose file:

yaml
12345
services:
app:
image: myapp:latest
env_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:

yaml
12345
env_file:
- path: ./app.env
required: true
- path: ./app.local.env
required: 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:

text
123
# .env
TAG=v1.5
LOG_LEVEL=debug

Then reference it with ${...} substitution syntax in the Compose file:

yaml
12345
services:
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:

bash
1
docker 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:

yaml
12345
environment:
# use 8080 if APP_PORT is unset or empty
APP_PORT: ${APP_PORT:-8080}
# fail with a message if DATABASE_URL is missing
DATABASE_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:

dockerfile
12345
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-slim
ARG BUILD_ENV=production
RUN echo "Building for ${BUILD_ENV}"
bash
1
docker 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:

dockerfile
12
ENV LOG_LEVEL=info
ENV 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:

dockerfile
12
ARG BUILD_ENV=production
ENV 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:

  1. docker compose run -e on the CLI
  2. environment or env_file with a value interpolated from your shell or .env
  3. The environment attribute with a literal value
  4. The env_file attribute
  5. The image's ENV directive

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.