Bitdoze logo

How to Use Docker Environment Variables: ARG vs ENV

Learn how Docker environment variables work: ARG for build time, ENV for runtime, in Dockerfiles and Docker Compose. Examples, precedence rules, secrets.

Dragos

46 min read

How to Use Docker Environment Variables: ARG vs ENV

Docker environment variables look simple until a container silently ignores them. This guide covers the two Dockerfile instructions that set them: ARG for build time, and ENV for build time plus runtime. It also covers how Docker Compose layers interpolation, env_file, and precedence rules on top. You get working examples for each, plus verify commands to run after every change.

Everything here is checked against current Docker (October 2026): BuildKit has been the default builder since Engine 23, Compose is on v5.x (the old hyphenated docker-compose V1 command is long dead), and the version: field in compose files now emits an obsolete warning. The mental model first, then copy-pasteable snippets, then the failure modes that make environment variables “not work”, including the exec-form CMD trap that catches almost everyone.

What are Docker environment variables?

Environment variables are key/value pairs set in a process’s environment. Your application reads them at startup (or later) instead of hardcoding configuration. In Docker they do the same job: one image can point at a test database in one container and a production database in the next, without a rebuild.

Docker adds two instructions on top of plain process env. ARG feeds values during the image build. ENV sets values that persist in the image and land in the container’s environment at runtime. You can also inject variables at runtime without touching the image at all, with docker run -e or a Docker Compose environment: block. That’s the whole toolbox.

Inside the container, how you read a variable depends on your language: os.environ in Python, process.env in Node, $VAR in a shell script. The variable itself is the same everywhere.

  • Build args with ARG and docker build --build-arg
  • Runtime config with ENV and docker run -e
  • Docker Compose environment variables, env_file, and .env interpolation
  • Which value wins: the Compose precedence order
  • Secrets done safely (build secret mounts, Compose secrets)
  • Verify commands for every step

More Docker guides on this site:

ARG vs ENV: how they differ

ARG ENV
Set at Build time Build time, persists at runtime
Visible in the running container No Yes
Override on docker run No Yes, with -e
Baked into image config No Yes
Visible in docker history Yes, if used in a layer Yes

One subtlety the official docs spell out: if an ENV and an ARG share a name, the ENV value wins for every RUN instruction after it. A --build-arg pass-through only works while the ARG is the source of truth. And while ARG does not become a runtime variable, values used in build layers are readable in docker history. Treat them as public (more on that in the secrets section).

Two mistakes that cost the most

Exec-form CMD [“python”, “app.py”] does not expand $VAR. If your app receives the literal string $DB_URL, that is why.

ARG values used in build layers show up in docker history. Never put passwords or tokens in ARG or ENV.

Docker environment variables: ARG vs ENV lifecycle from build to containerdocker build–build-arg NAME=xARGbuild stage onlyARG to ENVENVimage config-e overridecontaineros.environBuild time —————————> runtimedocker historyARG values used in layers leakENV is overridable per container:docker run -e DB_URL=… imageCompose adds interpolation (${VAR:-default}) and precedence: run -e beats environment: beats env_file: beats image ENVSecrets: use build –secret mounts and Compose secrets, never ARG or ENV

Using ARG variables in a Dockerfile (build args)

An ARG declares a variable that only exists during docker build. You pass values with --build-arg. If you do not pass one, the Dockerfile default applies.

Declare the ARG, reference it in later instructions, override it at build time:

dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-alpine

ARG APP_VERSION=0.0.0
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN echo "Building version ${APP_VERSION}"
CMD ["node", "server.js"]

You can declare several ARGs on one line (ARG APP_VERSION=0.0.0 APP_ENV=dev) and pass several --build-arg flags:

bash
docker build -t my_image \
  --build-arg NODE_VERSION=24 \
  --build-arg APP_VERSION=1.4.2 .

Verify the build succeeded, then check what leaked into image history:

bash
docker history my_image --no-trunc | grep APP_VERSION

If the build fails with “empty or invalid base image name”, a global ARG used in FROM was empty; see the scope section below. For the full docker build flag reference, see essential Docker commands.

ARG scope: global vs in-stage (before and after FROM)

ARG is the only instruction allowed before FROM, but you can also declare ARGs anywhere inside a stage. The rule people get wrong: an ARG declared before FROM is not inherited into the stage. To use it after FROM, re-declare it with no default inside the stage:

dockerfile
# syntax=docker/dockerfile:1
ARG VERSION=latest          # global scope, usable in FROM
FROM alpine:${VERSION}
ARG VERSION                 # bare re-declaration, picks up the global value
RUN echo "$VERSION"         # prints the value here

An ARG declared inside a stage is inherited by child stages based on that stage. Unrelated stages cannot see it. Two build checks police this area: UndefinedArgInFrom flags an undeclared variable used in FROM, and InvalidDefaultArgInFrom flags a global ARG with no usable default when you have not passed one.

The one line people forget

The bare ARG VERSION after FROM. Without it, $VERSION inside the stage is empty, even though –build-arg VERSION=1.2.3 worked fine in FROM.

Failure modes: FROM $VAR with an undeclared VAR triggers UndefinedArgInFrom. A global ARG BASE with no default and no --build-arg gives “empty or invalid base image name”. Fix both by declaring a default (ARG BASE=alpine:3.21) or passing the flag.

Overriding defaults with docker build --build-arg

--build-arg NAME=value is repeatable, one flag per variable. Omit the flag and the Dockerfile default applies. Declare an ARG with no default and pass nothing, and the value is an empty string, usually a bad time.

Two predefined groups are worth knowing:

  • Proxy args (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, ALL_PROXY and lowercase variants) are usable without declaring them. They are excluded from docker history by default. Skip the explicit ARG HTTP_PROXY line: re-declaring puts the value back into docker history and ties layer cache to it.
  • BuildKit platform args (TARGETPLATFORM, TARGETOS, TARGETARCH, TARGETVARIANT, BUILDPLATFORM) are available in global scope. Like any global ARG, they need a bare re-declaration inside a stage (ARG TARGETARCH) before you can use them there.

Using ENV variables in a Dockerfile

ENV sets variables that persist in the image config and appear in the environment of every container started from that image. They are set during the build and live at runtime. That is the whole difference from ARG.

dockerfile
# syntax=docker/dockerfile:1
FROM python:3.13-slim

ENV DB_URL=postgres://user:pass@localhost:5432/app \
    API_KEY=changeme \
    APP_PORT=8000

WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

Notes on syntax and scope:

  • Use ENV KEY=value, or multiple pairs on one line (ENV A=1 B=2). The old space form (ENV key value) is discouraged and may be removed in a future release. The LegacyKeyValueFormat build check flags it.
  • Use UPPER_SNAKE_CASE names. Every variable Docker and the major base images ship is uppercase; matching that convention saves confusion.
  • ENV inherits through multi-stage builds: “A stage inherits any environment variables that were set using ENV by its parent stage or any ancestor.” ARG does not. Child stages need their own declaration.
  • A container’s environment is namespaced per container. It can never overwrite your host variables. What runs inside stays inside.

The classic persistence gotcha is DEBIAN_FRONTEND=noninteractive. If you bake it as an ENV, it stays in the final image and surprises every interactive user later. Set it inline in the RUN instead:

dockerfile
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

If you want a full working Python service with this pattern, see how to run a Python app in Docker with Docker Compose.

Why $VAR doesn’t expand in exec-form CMD

This is the single most common reason “Docker environment variables are not working”. Exec form runs without a shell:

dockerfile
CMD ["python", "app.py", "$DB_URL"]

The app receives the literal string $DB_URL. No expansion, no pipes, no &&. Shell form expands, but then /bin/sh is PID 1 and swallows SIGTERM, so docker stop waits ten seconds and kills the container the hard way. Three options, in order of preference:

Read env in the app (recommended)

dockerfile
ENV DB_URL=postgres://user:pass@localhost:5432/app
CMD ["python", "app.py"]
python
import os
db_url = os.environ["DB_URL"]

Exec form stays, signal handling works, and the app gets the variable from its own environment. This is how the official images behave.

Shell form (quick)

dockerfile
CMD python app.py "$DB_URL"

Expansion works. The catch: /bin/sh becomes PID 1 and does not forward signals to Python. Acceptable for short-lived jobs, sloppy for a long-running web service.

Wrapper script (best of both)

dockerfile
COPY --chmod=755 <<EOT /entrypoint.sh
#!/bin/sh
set -e
exec python app.py "$DB_URL"
EOT
ENTRYPOINT ["/entrypoint.sh"]

The shell expands the variable, exec replaces the shell with Python, and Python becomes PID 1. Signals work again.

This is why your container ignores $DB_URL

Exec-form CMD [“python”, “app.py”, “$DB_URL”] passes the literal text $DB_URL to the app. Use one of the three options above. The JSONArgsRecommended build check warns about the signal half of this problem; run docker build –check . to see it.

Failure modes: the app logs a connection error containing $DB_URL or fails to parse it as a URL, which means exec form. docker stop hangs for ten seconds, which means shell form without exec. Fix: read the variable inside the app, or use the wrapper script.

Overriding ENV at runtime with docker run -e

Image ENV is a default, not a sentence. docker run -e overrides it per container:

bash
docker run -d --name app -p 8000:8000 \
  -e DB_URL=postgres://user:pass@db:5432/app \
  -e API_KEY=supersecret \
  my_image

Two forms, different behavior:

  • -e DB_URL=value sets or overrides the variable for this container only.
  • -e DB_URL (no =) passes through the value from your current shell. If it is unset there, the variable is absent in the container.

Neither form touches the host environment. docker run -e only ever writes values into the container.

For many variables, use a file:

bash
docker run --env-file env.list my_image
bash
# env.list
# full-line comments only; a # anywhere else is literal text
DB_URL=postgres://user:pass@db:5432/app
API_KEY=supersecret
USER

A bare USER line pulls that value from your local environment. There is no ${VAR} interpolation in docker run --env-file; that is a Compose-only feature.

Verify what the container got:

bash
docker exec app printenv | grep DB_URL

If the line is missing, the variable never made it in. Check the docker run flags and the image config with docker inspect (see the verify section).

Docker Compose environment variables

Compose is where most people meet Docker environment variables in anger, because the same value shows up in four places: the shell, a .env file, the compose file, and the image. First, two modernization notes.

Delete version: from your compose file

Compose validates with the latest schema regardless of the version: field. Keeping it just prints an obsolete warning. Also use the docker compose plugin (check with docker compose version; v5.x as of October 2026), not the dead hyphenated docker-compose V1.

A minimal service with explicit env vars:

yaml
services:
  app:
    image: my_image
    ports:
      - "8000:8000"
    environment:
      DB_URL: postgres://user:pass@db:5432/app
      API_KEY: supersecret

When you deploy real stacks this way, a control panel helps. I run Docker Compose apps on a VPS through Dokploy, and env files are part of every deploy; see how to deploy a Docker Compose app in Dokploy.

Setting vars with environment: and env_file:

environment: accepts list or map form. Map form is what I use; it reads better and makes overrides obvious:

yaml
# list form
    environment:
      - DB_URL=postgres://user:pass@db:5432/app
      - API_KEY=supersecret
yaml
# map form (same values, easier to override one key)
    environment:
      DB_URL: postgres://user:pass@db:5432/app
      API_KEY: supersecret

env_file: loads a whole file. Paths are relative to the compose file. With several entries, later files override earlier ones:

yaml
services:
  app:
    image: my_image
    env_file:
      - ./app.env
      - ./overrides.env

Long syntax is worth knowing for optional files:

yaml
    env_file:
      - path: ./app.env
        required: false   # silently ignored when missing (Compose >= v2.24.0)
      - path: ./extra.env
        required: true
        format: yaml      # alternative format (Compose >= v2.30.0)

One-off overrides without editing anything: docker compose run -e DEBUG=1 app or docker compose exec -e DEBUG=1 app. Official images like Postgres and MySQL also accept a _FILE variant of their variables (POSTGRES_PASSWORD_FILE); more on that in the secrets section.

Interpolation and .env files

Interpolation happens in the Compose CLI when it parses the compose file. It is not a Docker Engine feature. Supported syntax:

yaml
image: webapp:${TAG}                # value from shell or .env
environment:
  LOG_LEVEL: ${LOG_LEVEL:-info}     # default if unset OR empty
  MODE: ${MODE-dev}                 # default only if unset
  DB_URL: ${DB_URL:?set DB_URL}     # fail if unset or empty
  BANNER: ${MSG:+configured}        # alternative if set

A .env file in the project directory feeds interpolation automatically. Parsing rules that trip people up:

bash
TAG=1.4.2
DB_URL=postgres://user:pass@db:5432/app
LITERAL='${TAG}'      # single quotes: literal, no interpolation
EXPANDED="${TAG}"     # double quotes: interpolation and \n \t escapes work
LOG_LEVEL=debug       # inline comments need a leading space before #

Where Compose looks for interpolation values, highest first: your shell environment, then --env-file files (later wins), then the project .env. Two scope limits: docker run --env-file does not interpolate, and neither does Swarm docker stack deploy.

Failure mode: a ${VAR} marker arrived at the app as literal text. Either you used it in docker run --env-file (unsupported) or the .env value is single-quoted.

Which value wins: Compose precedence order

For the environment of a running container, from highest to lowest:

  1. docker compose run -e VAR=...
  2. environment: / env_file: entries whose value was interpolated from the shell or .env
  3. environment: in the compose file
  4. env_file: in the compose file
  5. Image ENV from the Dockerfile

The gotcha that bites everyone: a host variable that is never referenced by environment:, env_file:, or run -e does not reach the container. Compose does not auto-inject your shell environment. If your container ignores a variable you exported, this is why.

Docker Compose environment variable precedence ladderHighest priority1. docker compose run -e VAR=…2. environment: / env_file: (interpolated)3. environment: in compose.yaml4. env_file: in compose.yaml5. image ENV (Dockerfile default)Lowest priorityUnreferenced host varA variable in your shell thatno environment:, env_file:,or run -e entry referencesnever reaches the container

Verify the resolved result before you deploy:

bash
docker compose config
docker compose config --environment   # if your Compose version supports it

docker compose config prints the effective compose file after interpolation and env_file merging. If the value is wrong there, the container will be wrong too.

Using ARG and ENV together

The bridge pattern: declare an ARG, copy it into an ENV, and the build argument becomes the runtime default.

dockerfile
# syntax=docker/dockerfile:1
ARG APP_PORT=8080
ENV APP_PORT=${APP_PORT}
CMD ["node", "server.js"]

The app reads process.env.APP_PORT, with no exec-form expansion involved (see the fix above). Build with --build-arg APP_PORT=9090 and the image ships with APP_PORT=9090 as its default. Run with -e APP_PORT=3000 and that still wins for the container.

Be precise about what this does: values you bridge with ENV are in the runtime environment (that is the point of the bridge). Values you leave as ARG-only are not, and are only usable in build instructions.

Verify the baked default:

bash
docker build -t my_image --build-arg APP_PORT=9090 .
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' my_image | grep APP_PORT
# APP_PORT=9090

The official docs use the same shape: ENV CONT_IMG_VER=${CONT_IMG_VER:-v1.0.0} with an ARG above it. Note the interaction: after ENV APP_PORT=... is set, a later RUN that reads $APP_PORT sees the ENV value even if --build-arg changed the ARG.

Passing secrets safely: don’t use ARG or ENV for credentials

Environment variables are convenient and leaky. Anyone who can run docker inspect on the container sees them, as does docker exec printenv and /proc/<pid>/environ. ARG is worse: values used in build layers sit in docker history forever, and provenance attestations can carry them too. Docker’s own ARG docs say it plainly: it isn’t recommended to use build arguments for passing secrets.

Never put passwords or API tokens in ARG or ENV

They end up in docker history and docker inspect. Use build secret mounts and Compose secrets instead.

At build time, pass secrets as mounts that never enter a layer:

dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.21

# secret as a file at /run/secrets/API_TOKEN
RUN --mount=type=secret,id=API_TOKEN \
    ./fetch-artifact.sh "$(cat /run/secrets/API_TOKEN)"

# or mounted straight into an env var for that single RUN
RUN --mount=type=secret,id=AWS_ACCESS_KEY_ID,env=AWS_ACCESS_KEY_ID \
    --mount=type=secret,id=AWS_SECRET_ACCESS_KEY,env=AWS_SECRET_ACCESS_KEY \
    aws s3 cp s3://my-bucket/file .
bash
docker build --secret id=API_TOKEN .                        # from local env var API_TOKEN
docker build --secret id=AWS_ACCESS_KEY_ID,src=./aws_key .  # from a file
docker build --secret id=aws,target=/root/.aws/credentials .
docker build --ssh default=.                               # private repos over SSH agent

Secrets default to /run/secrets/<id>; target= renames the path and env= mounts as an env var instead of a file.

At runtime, Compose secrets work without Swarm. Define the secret at top level, attach it to the service, and it appears as a file in the container:

yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./db_password.txt

The _FILE convention (POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE) is how the official images expect this. Plain Docker secrets are Linux-container only (bind-mounted single files). For more options, including what works and what does not, see Docker Compose secrets.

BuildKit build checks can flag the old habits: run docker build --check . and look for SecretsUsedInArgOrEnv, which matches key names like *TOKEN* and AWS_*_KEY.

  • Build secrets via docker build --secret and RUN --mount=type=secret
  • Runtime secrets via Compose secrets: mounted at /run/secrets/NAME
  • _FILE variants where the image supports them
  • docker build --check . before publishing an image
  • Nothing sensitive in a .env file that git can see

Cost and ops: secret mounts cost nothing extra at runtime. The constraint is operational: the secret file must exist at build or deploy time, so on a VPS keep it outside the repo (root-owned, mode 600) or in a secrets manager. If docker history still shows your token, you used ARG or ENV. If /run/secrets is empty in the container, the service-level secrets: entry is missing. If the build cannot find a secret, the src= path is wrong.

How to verify your environment variables

Do not guess. After every change, check what the container has:

  1. For the live container env, run docker exec <container> printenv. It prints everything the process sees, one line per variable.
  2. For the image or container config, run docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' <container-or-image>. This is what Docker recorded at create time, independent of runtime mutations.
  3. For what leaked into layers, run docker history my_image --no-trunc. Look for build args and ENV values sitting in image history.
  4. For the resolved Compose config, run docker compose config. It prints the effective file after interpolation and env_file merging. docker compose config --environment lists the interpolated variables if your Compose version supports it; docker compose config alone works everywhere.
  5. For a Dockerfile lint, run docker build --check .. It catches the usual ARG/ENV mistakes: SecretsUsedInArgOrEnv, JSONArgsRecommended, UndefinedArgInFrom, InvalidDefaultArgInFrom, UndefinedVar, LegacyKeyValueFormat.

If printenv disagrees with your compose file, the precedence ladder above decides who won; docker compose config shows the same answer from the Compose side. A broader command reference is in essential Docker commands.

Best practices for Docker environment variables

  • Use ARG for build-time values (version numbers, target platforms), never for credentials.
  • Use ENV for runtime config the app reads; keep the app reading os.environ / process.env.
  • Never put passwords or tokens in ARG or ENV; use secret mounts and Compose secrets.
  • Name everything UPPER_SNAKE_CASE and keep names consistent across services.
  • Document required variables with an env.example file in the repo (names and dummy values, real values stay out).
  • Give non-sensitive vars sensible defaults, either in ENV or with compose-style default syntax.
  • Use .env for local development. Do not commit it, and do not make it your only production mechanism.

These cost nothing at deploy time and prevent the two incidents that show up most often: leaked credentials and containers that quietly ignore their config.

Common issues with Docker environment variables

Symptom, cause, fix:

  1. The app receives the literal string $DB_URL. Cause: exec-form CMD does not expand variables. Fix: read the env var in the app, use shell form, or a wrapper script with exec.
  2. --build-arg had no effect inside my stage. Cause: the ARG was declared before FROM and never re-declared in the stage. Fix: add a bare ARG NAME after FROM.
  3. Compose ignores my host environment variable. Cause: unreferenced host vars never reach the container. Fix: declare it in environment: or env_file:.
  4. ${VAR} came through as literal text. Cause: interpolation is a Compose CLI feature (it does not work in docker run --env-file or Swarm stacks), or the .env value is single-quoted. Fix: interpolate in the compose file, not in the env file.
  5. The new value did not apply to a running container. Cause: env is fixed when the container is created. Fix: docker compose up -d to recreate, or docker rm -f and run again. A plain restart is not always enough if the image config itself changed.
  6. The wrong value wins. Cause: precedence confusion. Fix: docker compose config and read the resolved value; compare against the ladder above.
  7. “Docker overwrote a variable on my host.” It cannot. Container env is namespaced per container. What you saw is the opposite direction: docker run -e VAR with no = passes the host value into the container.

When the container is misbehaving after an env change, logs help: redirect Docker logs to a single file and grep for the variable name your app logs at startup.

FAQ

Do Docker ENV variables persist after the container stops?

ENV values baked into the image persist as long as the image does. Values passed with docker run -e belong to that container only. If you remove and recreate the container, you must pass them again. If you bake secrets in as ENV, they persist in the image, which is exactly the problem. Use secret mounts instead.

What is the difference between Dockerfile ENV and ARG?

ARG is build-time only and does not appear in the container’s environment. ENV is set at build time and persists at runtime. ARG values used in layers are still visible in docker history, so ARG is not a secrets mechanism. To bridge them, ENV MY_VAR=${MY_ARG} copies the build value into the runtime default.

Can I use .env files with docker run?

Yes, with docker run –env-file env.list. The format is plain KEY=value lines and bare KEY names that pull from your local shell. Full-line # comments only. There is no ${VAR} interpolation in that file; interpolation is a Compose CLI feature.

Where do Docker Compose secrets live?

At /run/secrets/<name> inside the container, as a bind-mounted single file. This works with plain Docker Compose on Linux containers, no Swarm required. Images that follow the _FILE convention (Postgres, MySQL) read them directly via POSTGRES_PASSWORD_FILE and friends.

Why is version: in my compose file obsolete?

Compose validates every file with the latest schema regardless of that field, so it only prints a warning. Delete it. Also confirm you are on the docker compose plugin (docker compose version, currently v5.x) rather than the removed V1 docker-compose command.

Conclusion

The mental model is small: ARG feeds the build, ENV feeds the build and the container, and Docker Compose adds interpolation plus a precedence order you can always resolve with docker compose config. The two expensive mistakes are exec-form CMD eating your variables and credentials in ARG or ENV leaking through docker history. Secret mounts fix both. Run the verify commands after every change and “it ignores my env var” stops being a mystery.

Everything above runs the same on a small VPS, which is how I deploy most of these stacks. Hetzner Cloud is my default when I need a cheap reliable EU box for Docker and Compose.

Explore more Docker guides