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.
46 min read

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
ARGanddocker build --build-arg - Runtime config with
ENVanddocker run -e - Docker Compose environment variables,
env_file, and.envinterpolation - 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:
- add a non-root user to a Docker container
- copy multiple files in one Dockerfile layer
- install Docker on Ubuntu
- redirect Docker logs to a single file
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.
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:
# 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:
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:
docker history my_image --no-trunc | grep APP_VERSIONIf 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:
# 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 hereAn 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_PROXYand lowercase variants) are usable without declaring them. They are excluded fromdocker historyby default. Skip the explicitARG HTTP_PROXYline: re-declaring puts the value back intodocker historyand 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.
# 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. TheLegacyKeyValueFormatbuild check flags it. - Use
UPPER_SNAKE_CASEnames. 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:
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:
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)
ENV DB_URL=postgres://user:pass@localhost:5432/app
CMD ["python", "app.py"]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)
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)
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:
docker run -d --name app -p 8000:8000 \
-e DB_URL=postgres://user:pass@db:5432/app \
-e API_KEY=supersecret \
my_imageTwo forms, different behavior:
-e DB_URL=valuesets 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:
docker run --env-file env.list my_image# env.list
# full-line comments only; a # anywhere else is literal text
DB_URL=postgres://user:pass@db:5432/app
API_KEY=supersecret
USERA 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:
docker exec app printenv | grep DB_URLIf 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:
services:
app:
image: my_image
ports:
- "8000:8000"
environment:
DB_URL: postgres://user:pass@db:5432/app
API_KEY: supersecretWhen 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:
# list form
environment:
- DB_URL=postgres://user:pass@db:5432/app
- API_KEY=supersecret# map form (same values, easier to override one key)
environment:
DB_URL: postgres://user:pass@db:5432/app
API_KEY: supersecretenv_file: loads a whole file. Paths are relative to the compose file. With several entries, later files override earlier ones:
services:
app:
image: my_image
env_file:
- ./app.env
- ./overrides.envLong syntax is worth knowing for optional files:
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:
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 setA .env file in the project directory feeds interpolation automatically. Parsing rules that trip people up:
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:
docker compose run -e VAR=...environment:/env_file:entries whose value was interpolated from the shell or.envenvironment:in the compose fileenv_file:in the compose file- Image
ENVfrom 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.
Verify the resolved result before you deploy:
docker compose config
docker compose config --environment # if your Compose version supports itdocker 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.
# 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:
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=9090The 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:
# 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 .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 agentSecrets 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:
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txtThe _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 --secretandRUN --mount=type=secret - Runtime secrets via Compose
secrets:mounted at/run/secrets/NAME _FILEvariants where the image supports themdocker build --check .before publishing an image- Nothing sensitive in a
.envfile 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:
- For the live container env, run
docker exec <container> printenv. It prints everything the process sees, one line per variable. - 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. - For what leaked into layers, run
docker history my_image --no-trunc. Look for build args and ENV values sitting in image history. - For the resolved Compose config, run
docker compose config. It prints the effective file after interpolation andenv_filemerging.docker compose config --environmentlists the interpolated variables if your Compose version supports it;docker compose configalone works everywhere. - 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_CASEand keep names consistent across services. - Document required variables with an
env.examplefile in the repo (names and dummy values, real values stay out). - Give non-sensitive vars sensible defaults, either in
ENVor with compose-style default syntax. - Use
.envfor 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:
- The app receives the literal string
$DB_URL. Cause: exec-formCMDdoes not expand variables. Fix: read the env var in the app, use shell form, or a wrapper script withexec. --build-arghad no effect inside my stage. Cause: the ARG was declared beforeFROMand never re-declared in the stage. Fix: add a bareARG NAMEafterFROM.- Compose ignores my host environment variable. Cause: unreferenced host vars never reach the container. Fix: declare it in
environment:orenv_file:. ${VAR}came through as literal text. Cause: interpolation is a Compose CLI feature (it does not work indocker run --env-fileor Swarm stacks), or the.envvalue is single-quoted. Fix: interpolate in the compose file, not in the env file.- The new value did not apply to a running container. Cause: env is fixed when the container is created. Fix:
docker compose up -dto recreate, ordocker rm -fand run again. A plain restart is not always enough if the image config itself changed. - The wrong value wins. Cause: precedence confusion. Fix:
docker compose configand read the resolved value; compare against the ladder above. - “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 VARwith 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

