Dockerfile ENV vs ARG
ARG exists only during the build; ENV persists into every container. Scoping across stages, why ARG leaks secrets, and the effect on cache.
Both define variables, but they live in different phases of the image's life. ARG exists only while the image is being built. ENV is baked into the image and present in every container started from it.
Getting them backwards produces one of two outcomes: a variable your application cannot see at runtime, or a secret permanently readable by anyone who pulls the image.
The core difference
ARG | ENV | |
|---|---|---|
| Available during | Build only | Build and runtime |
| Present in a running container | No | Yes |
Shown by docker inspect | No | Yes |
Recorded in docker history | Yes | Yes |
| Set with | --build-arg | -e at docker run |
| Safe for secrets | No | No |
That fourth row is the one people miss. ARG is gone at runtime, which feels private, but the value it was given is written into the image's build metadata regardless.
ENV: runtime configuration
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info
One instruction for several variables, because each ENV is its own layer. The older space-separated form (ENV NODE_ENV production) still parses but is deprecated and cannot set multiple values. Use =.
Values are visible to every RUN that follows, to the container's main process, and to any later docker exec session:
docker run --rm myapp env | grep NODE_ENV
NODE_ENV=production
ENV sets a default, and -e overrides it without a rebuild:
docker run -e LOG_LEVEL=debug -e PORT=4000 myapp
That is the point of it: the image ships sensible defaults, the deployment overrides what differs. Precedence runs docker run -e → Compose environment: → Compose env_file: → Dockerfile ENV, with the leftmost winning.
ENV also expands earlier variables, which is how you extend PATH:
ENV PATH="/opt/venv/bin:$PATH"
Good uses: default ports and log levels, PATH-style variables, tool configuration such as PIP_NO_CACHE_DIR=1 or PYTHONUNBUFFERED=1.
Never: API tokens, passwords, connection strings with credentials. Those come from the orchestrator at runtime.
ARG: build-time parameters
ARG APP_VERSION=1.0.0
ARG GIT_COMMIT
RUN echo "Building $APP_VERSION from $GIT_COMMIT"
docker build \
--build-arg APP_VERSION=2.1.0 \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
-t myapp:2.1.0 .
With no default and no --build-arg, the value is simply empty. The build does not fail. That silent emptiness is worth guarding when the value matters:
ARG APP_VERSION
RUN test -n "$APP_VERSION" || (echo "APP_VERSION is required" && false)
Scoping is where ARG surprises people
ARG before FROM
ARG is the only instruction allowed before FROM, which is how you parameterise the base image:
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine
# The pre-FROM ARG is out of scope here, re-declare to use it
ARG NODE_VERSION
RUN echo "Running on Node $NODE_VERSION"
Omit that second declaration and $NODE_VERSION expands to nothing, with no warning:
Running on Node
Each stage needs its own declaration
The same rule applies to every stage of a multi-stage build:
ARG APP_VERSION=1.0.0
FROM golang:1.23 AS build
ARG APP_VERSION # re-declared for this stage
RUN go build -ldflags "-X main.version=$APP_VERSION" -o /out/server .
FROM alpine:3.20
ARG APP_VERSION # and again for this one
ENV APP_VERSION=${APP_VERSION}
COPY --from=build /out/server /server
CMD ["/server"]
Promoting ARG to ENV
The standard pattern when a build-time value must also exist at runtime:
ARG APP_VERSION=1.0.0
ENV APP_VERSION=${APP_VERSION}
docker build --build-arg APP_VERSION=2.5.0 -t myapp:2.5.0 .
docker run --rm myapp:2.5.0 sh -c 'echo $APP_VERSION'
2.5.0
Do this only for things that are safe to publish: a version string, a build date, a commit SHA. Promoting a token to ENV takes something already in the build history and makes it visible in docker inspect too.
Parameterize a Build
You want to build an image tagged myapp:3.0.0, passing a build argument APP_VERSION=3.0.0 (which the Dockerfile promotes from ARG to ENV). Then you want to run the container and print the value of APP_VERSION from inside it. Which sequence of commands is correct?
Why ARG is not safe for secrets
This looks careful. The file is deleted in the same layer:
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm ci \
&& rm .npmrc
But the value was passed as a build argument, and build arguments are recorded:
docker history myapp:1.0 --no-trunc | grep NPM_TOKEN
|1 NPM_TOKEN=npm_9fK2xQ... /bin/sh -c echo "//registry.npmjs.org/..."
Anyone who can pull the image can read that. Rotating the token is the only remedy once an image has shipped.
The fix is a BuildKit secret mount, which makes the value available to one instruction and writes it to no layer:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc \
&& npm ci \
&& rm .npmrc
docker build --secret id=npm_token,env=NPM_TOKEN -t myapp .
ARG and the build cache
An ARG invalidates the cache from the point it is first used, not from where it is declared. Declaring ten build args and using one changes nothing about the other nine.
That makes it a precise cache-busting tool:
ARG CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .
Everything above that line stays cached; everything from it rebuilds.
Predefined build arguments
Some args exist without being declared. The platform ones are what make cross-compilation work:
FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/server .
The build stage runs natively on the builder and cross-compiles to the target, which avoids emulation entirely and is what stops an arm64 build producing an exec format error on amd64 servers.
HTTP_PROXY, HTTPS_PROXY and NO_PROXY are also predefined and, usefully, are excluded from docker history.
Common mistakes
| Mistake | Consequence |
|---|---|
ARG for a token | Permanently in docker history |
ENV for a secret | In docker history and docker inspect |
Pre-FROM ARG not re-declared | Expands to empty, silently |
ARG not re-declared per stage | Same silent empty value |
Expecting ARG at runtime | Variable missing inside the container |
ENV where -e would do | Rebuild required to change config |
One ENV per variable | One layer per variable |
Quick reference
| Task | Syntax |
|---|---|
| Runtime default | ENV PORT=3000 |
| Several at once | ENV A=1 \ + B=2 |
| Build parameter | ARG APP_VERSION=1.0.0 |
| Pass at build | docker build --build-arg APP_VERSION=2.0 . |
| Override at run | docker run -e PORT=8080 myapp |
| Parameterise the base image | ARG V=22 before FROM node:${V}-alpine |
| Build-time secret | RUN --mount=type=secret,id=<id> … |
| Inspect runtime values | docker inspect -f '{{.Config.Env}}' <image> |
Key takeaways
ARGis build-time only;ENVpersists into every container. That is the whole distinction.- Neither is safe for secrets.
ARGlands indocker history,ENVindocker inspectas well. - Use a BuildKit secret mount for tokens. Available to one instruction, written to no layer.
ARGbeforeFROMmust be re-declared after it, and again in every stage that uses it.ENVis a default, not a fixture:-eoverrides it without rebuilding.- An
ARGbusts the cache from first use, which makes it a precise rebuild trigger. TARGETARCHandBUILDPLATFORMare free, and they are how cross-compilation avoids emulation.
Check your understanding
A version string that vanishes
A Dockerfile declares ARG NODE_VERSION=22 before FROM, uses it successfully in the FROM line, then RUN echo "Node $NODE_VERSION" prints "Node" with no number. Why?
Frequently Asked Questions
What is the difference between ARG and ENV in a Dockerfile?
ARG defines a variable that exists only while docker build runs and is gone once the image is finished. Nothing inside a running container can see it. ENV writes a variable into the image so it is present during the remainder of the build and in every container started from that image. Use ARG for things that shape how the image is built, such as a base image version or a commit SHA, and ENV for configuration the application reads at runtime, such as a port or log level.
Is it safe to pass a secret with --build-arg?
No. The value is recorded in the image's build metadata and docker history --no-trunc prints it back, even if the file you wrote it to was deleted in the same RUN. Anyone who can pull the image can recover it, and rotating the credential is the only fix after the fact. Use a BuildKit secret mount instead, RUN --mount=type=secret,id=token … with docker build --secret id=token,env=TOKEN, which exposes the value to a single instruction and writes it to no layer.
Why is my ARG empty after FROM?
Because the region before the first FROM is a separate scope, and build stages do not inherit from it. That scope exists so the base image itself can be parameterised. Add a bare ARG NODE_VERSION after FROM to bring the value into that stage, and repeat it in every stage of a multi-stage build that needs it. Docker gives no warning. An out-of-scope variable simply expands to an empty string.
How do I override an environment variable at runtime?
docker run -e LOG_LEVEL=debug myapp overrides whatever the Dockerfile's ENV set, with no rebuild. In Compose, use environment: for inline values or env_file: for a file. Precedence runs from most to least specific: docker run -e beats Compose environment:, which beats env_file:, which beats the Dockerfile's ENV. Treat ENV as the image's default rather than a fixed value.
Why does changing --build-arg rebuild my whole image?
It does not rebuild the whole image. It invalidates the cache from the point the argument is first used, and everything above stays cached. If that feels like everything, the ARG is probably referenced earlier than it needs to be, often in a FROM line or an early RUN. Moving its first use further down keeps the expensive install layers cached. The same behaviour is useful deliberately: ARG CACHE_BUST placed just above an apt-get upgrade forces exactly that step to re-run.
Should I use ENV or a .env file for configuration?
Different tools for different jobs. Dockerfile ENV bakes a default into the image, which is right for values that are part of the image itself: a PATH addition, PYTHONUNBUFFERED=1, a default port. A .env file is read by Docker Compose on your machine and never enters the image, which makes it right for per-environment values and for anything you would not publish. In production the orchestrator supplies the values, typically from a Kubernetes Secret or a parameter store, and the image's ENV defaults just fill the gaps.