TechEarl

Docker Environment Variables: --env, --env-file, ARG vs ENV

How to pass env vars into containers: -e, --env-file, and the Compose environment / env_file blocks. Plus the ARG vs ENV distinction (build time vs runtime), the secrets-in-image-history trap, and the precedence rules.

Ishan KarunaratneIshan Karunaratne⏱️ 7 min readUpdated
Share thisCopied

There are four ways to put values into a container: ENV in the Dockerfile, -e / --env-file at run time, Compose's environment: / env_file:, and (build-time only) ARG. They cover different lifecycle stages and have different security characteristics. The one to be careful about is anything that touches secrets at build time — those leak into the image's history.

ENV (runtime) vs ARG (build time)

dockerfile
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine
ENV NODE_ENV=production

ARG is available only at build time. Used by FROM, RUN, COPY, etc. Disappears when the image is built; the running container can't see it.

ENV sets an environment variable that exists at both build time (for later RUN instructions) and persists into the running container. The container's processes see it via the standard process.env / os.environ / $ENV lookup.

Rule of thumb:

  • Need a value at build time only (like a base image version, a Git ref to clone)? ARG.
  • Need it in the running container? ENV.
  • Need it at both? Pair them: ARG X + ENV X=$X.

Passing values at build time

bash
docker build --build-arg NODE_VERSION=22 -t my-app .

That sets the ARG NODE_VERSION declared in the Dockerfile.

You can also have an ARG with no default and require the build to pass one:

dockerfile
ARG GIT_COMMIT
RUN echo "Built from commit $GIT_COMMIT" > /etc/built-from
bash
docker build --build-arg GIT_COMMIT=$(git rev-parse HEAD) -t my-app .

Why --build-arg is bad for secrets

bash
docker build --build-arg API_KEY=supersecret -t my-app .   # DON'T

That value ends up in the image's build history. Anyone with image-pull access can run docker history my-app --no-trunc and see the value. Same for the inspect output.

Use BuildKit's secret mount instead:

dockerfile
RUN --mount=type=secret,id=api-key cat /run/secrets/api-key && do-something
bash
docker build --secret id=api-key,src=./api-key.txt -t my-app .

The secret is mounted at /run/secrets/api-key during the RUN only, doesn't appear in any layer, doesn't appear in image history. BuildKit is the default builder in Docker 23.0+ so this works out of the box.

Passing env vars at run time

bash
# Single var
docker run -e KEY=VALUE :image

# Multiple
docker run -e KEY1=value1 -e KEY2=value2 :image

# Pass through the host's env var of the same name (no = sign)
docker run -e DATABASE_URL :image

# Load from a file
docker run --env-file .env :image

--env-file reads a file of KEY=VALUE lines. Comments (#), blank lines, and inline whitespace are tolerated; quotes are NOT processed — KEY="value" ends up as KEY set to the literal "value" (with the quotes). Strip quotes from your .env before passing.

A typical .env:

code
DATABASE_URL=postgres://user:pass@db:5432/myapp
REDIS_URL=redis://cache:6379
API_KEY=...

Compose: environment and env_file

yaml
services:
  app:
    image: my-app
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://user:pass@db:5432/myapp
    env_file:
      - .env
      - .env.production

Two distinct mechanisms:

  • environment: — explicit key-value pairs in the YAML. Visible in docker compose config.
  • env_file: — load from one or more .env files. Each file is plain KEY=VALUE lines, same format as docker run --env-file.

Both end up as environment variables inside the container.

Compose variable substitution: a separate thing

Compose also has variable substitution inside the YAML itself:

yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}      # substituted from the host's env

${DB_PASSWORD} is substituted at docker compose up time, using:

  1. The host's shell environment, or
  2. A .env file in the same directory as docker-compose.yml.

That .env file is a Compose-specific thing — its values feed the YAML, not the container. It's a common confusion: people put DATABASE_URL=... in .env and expect it inside the container, but unless they also reference ${DATABASE_URL} somewhere in docker-compose.yml (or use env_file: .env), it's invisible to the container.

So .env next to docker-compose.yml serves two roles depending on how you reference it:

  • Referenced via ${VAR} in the YAML — feeds the compose file at parse time.
  • Referenced via env_file: .env — gets loaded into the container as env vars.

You can do both. They're not the same.

Precedence: which value wins

When the same env var is set in multiple places, this is the order (later wins):

  1. ENV in the Dockerfile (lowest precedence)
  2. Compose env_file: entries
  3. Compose environment: block
  4. Compose YAML variable substitution
  5. Shell environment passed to docker compose up
  6. -e on docker run (highest precedence)

So -e KEY=runtime on docker run always wins over ENV KEY=image in the Dockerfile. Useful for overriding image defaults at deployment.

A clean pattern for secrets

The setup that scales:

  • Non-sensitive config (NODE_ENV, LOG_LEVEL, public URLs) → Dockerfile ENV or Compose environment:.
  • Secrets (database passwords, API keys, JWT secrets) → Compose env_file: .env or docker run --env-file, where .env is gitignored and never committed.
  • CI / production secrets → injected by your orchestrator (Kubernetes secrets, Compose with secrets in the host env, Docker Swarm secrets, AWS Parameter Store, etc.).
  • Build-time secrets → BuildKit --secret mount, never --build-arg.

The line that catches most teams once: .env in .dockerignore so COPY . . doesn't ship it into the image. See .dockerignore Best Practices.

Common pitfalls

  • Putting secrets in --build-arg. They end up in image history. Use BuildKit's --mount=type=secret instead.
  • Expecting .env next to docker-compose.yml to land in the container. It feeds YAML substitution, not the container, unless you also reference it in env_file:.
  • Copying .env into the image. Even if it's a runtime config, baking it into the image kills the ability to deploy the same image to multiple environments. Pass at runtime.
  • environment: ARRAY_FORMAT syntax confusion. Compose accepts both map and list forms:
    yaml
    environment:
      KEY: value          # map form
    environment:
      - KEY=value         # list form
    Map form is more readable; list form is required if your key contains characters YAML doesn't like as keys (rare).
  • Quotes in .env files getting included literally. KEY="value" in .env is parsed as KEY set to "value" (with the quotes). Strip the quotes.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerEnvironment VariablesARGENVSecretsDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

d

docker logs: View Container Output and Tail Logs

Read the stdout and stderr of a running or stopped container. Follow live output, tail the last N lines, filter by time, prepend timestamps, and the cases where docker logs doesn't help because the app writes to a file instead.