properly explained
ARG and ENV look interchangeable at first glance, both set a named value after all. Anyone who does not know the difference in visibility, runtime behavior, and multi-stage build scoping easily ends up with images that produce unexpected values at runtime or, worse, accidentally bake credentials permanently into an image layer.
Table of Contents
- 1. Two kinds of variables with a different lifecycle
- 2. Why a container does not know an ARG value
- 3. Setting and overriding ARG values at build time
- 4. Visibility across multi-stage build boundaries
- 5. The common mistake: smuggling secrets in via ARG
- 6. The proper solution: BuildKit secret mounts
- 7. Typical use cases for ARG and ENV compared
- 8. ARG in docker-compose.yml: the build.args section
- 9. More details on scoping behavior
- 10. Summary
- 11. FAQ
1. Two kinds of variables with a different lifecycle
ARG and ENV both define named values that can be referenced inside a Dockerfile, yet their lifecycle differs fundamentally. A variable declared with ARG exists exclusively during the build process. It is available inside RUN, COPY, and other build time instructions, but disappears completely once the finished image has been created. In a running container started from that image, a pure ARG variable is nowhere to be found.
A variable declared with ENV, on the other hand, is baked permanently into the image metadata and is available both during the rest of the build and as a real environment variable in every container later started from that image. So anyone who needs a value that the application should read at runtime via getenv or process.env must use ENV, ARG alone is not enough for that.
FROM node:20-alpine
# ARG: only visible during the build
ARG BUILD_VERSION=dev
RUN echo "Building version ${BUILD_VERSION}"
# ENV: also visible at runtime inside the container
ENV APP_ENV=production
CMD ["node", "server.js"]
2. Why a container does not know an ARG value
A common misunderstanding is assuming that a value set via --build-arg is also available in the running container simply because it was visible while building the image. That is not the case: ARG values do flow into commands executed during RUN instructions and can be written to files, compiled, or otherwise processed there, but they do not automatically become part of the final image configuration. Once the build finishes, the variable simply ceases to exist.
Anyone who needs a build time value at runtime as well must explicitly carry it over from ARG to ENV, by having ENV reference the ARG variable's value. This combination is an established pattern: ARG accepts the value from the outside, for example via --build-arg, and ENV makes it a permanent part of the image, including visibility inside the later container.
FROM python:3.12-slim
# Accept the value from outside
ARG APP_VERSION=1.0.0
# Explicitly carry it over into a runtime environment variable
ENV APP_VERSION=${APP_VERSION}
# Now visible at runtime inside the container:
# docker run my-image env | grep APP_VERSION
CMD ["python", "app.py"]
3. Setting and overriding ARG values at build time
Values for ARG variables can be set when calling docker build via the --build-arg option, for example docker build --build-arg BUILD_VERSION=2.4.1. If the Dockerfile defines a default via ARG BUILD_VERSION=dev, that default is only used when no matching --build-arg is passed. If both the default and the --build-arg are missing, the variable is empty within the build, which can lead to unexpected results in subsequent RUN instructions.
Docker additionally supports globally predefined ARG variables such as HTTP_PROXY, HTTPS_PROXY, or NO_PROXY, which are automatically available for build environments behind a proxy without needing to be declared in the Dockerfile. Custom variable names, however, always have to be explicitly declared via an ARG instruction in the Dockerfile before they can be referenced, otherwise the substitution silently evaluates to empty in the build log instead of throwing an error.
# Build with an explicit value for ARG BUILD_VERSION
docker build --build-arg BUILD_VERSION=2.4.1 -t mironsoft/app:2.4.1 .
# Set multiple ARG values at once
docker build \
--build-arg BUILD_VERSION=2.4.1 \
--build-arg NODE_ENV=production \
-t mironsoft/app:2.4.1 .
4. Visibility across multi-stage build boundaries
In multi-stage builds with several FROM instructions, an important rule applies that frequently causes confusion: an ARG declaration only applies within the build stage it appears in, or from the point where it is declared until the next FROM line. If a global ARG variable is declared before the first FROM line, it is in principle available in every stage, but it has to be referenced again via ARG inside each individual stage in order to be usable there.
This behavior surprises many developers who assume that an ARG variable set globally once is automatically visible in all subsequent stages. Without the repeated ARG line inside the given stage, the variable stays empty there, even if it was declared globally before the first FROM. ENV variables, in contrast, always apply only within the stage where they were set and are not automatically carried over into a later stage unless explicitly set again there.
# Global ARG before the first FROM line
ARG NODE_VERSION=20
# --- Stage 1: build ---
FROM node:${NODE_VERSION}-alpine AS build
# The global ARG must be referenced again here to be usable
ARG NODE_VERSION
RUN echo "Building with Node ${NODE_VERSION}"
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# --- Stage 2: runtime ---
FROM node:${NODE_VERSION}-alpine AS runtime
# Without repeating ARG here, NODE_VERSION would be empty in this stage
ARG NODE_VERSION
ENV NODE_VERSION=${NODE_VERSION}
COPY --from=build /app/dist /app/dist
CMD ["node", "/app/dist/server.js"]
5. The common mistake: smuggling secrets in via ARG
A widespread anti pattern is trying to smuggle sensitive values like API keys, database passwords, or npm registry tokens into the build process via ARG, for example ARG NPM_TOKEN followed by docker build --build-arg NPM_TOKEN=secret. The problem is not runtime visibility in the finished container, ARG values do not automatically end up there in the first place, but visibility in the build history: ARG values are stored in the image metadata history by default and can be read out of a finished image at any time using docker history --no-trunc.
Even if the value is only used in an early build stage and that stage is not carried over into the final image in a multi-stage build, it often remains visible in the build cache or the local history of intermediate images unless these are explicitly deleted. On top of that, every secret value passed via --build-arg typically also ends up in shell history, CI/CD logs, and registry build metadata, which effectively compromises it permanently, even if it never shows up anywhere in the running container later on.
# ANTI-PATTERN: smuggling a secret via ARG, ends up in the image history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
&& npm ci
# docker history --no-trunc shows NPM_TOKEN in plain text!
# CORRECT: BuildKit secret mount, never ends up in the image or history
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) && \
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && \
npm ci
# Invocation: docker build --secret id=npm_token,src=./npm_token.txt .
6. The proper solution: BuildKit secret mounts
BuildKit, Docker's modern build backend, offers --mount=type=secret as a dedicated mechanism for exactly this problem. A value passed via --secret is made available during the given RUN instruction as a temporary file under /run/secrets/
This approach solves the problem cleanly at the root instead of papering over it afterward: secrets never become part of the image, neither as an ARG nor accidentally through a COPY instruction. For CI/CD pipelines, the secret value is typically written from a securely managed environment variable or a secret manager into a temporary file, passed to the docker build call, and deleted immediately afterward.
7. Typical use cases for ARG and ENV compared
ARG is an excellent fit for anything that solely controls the build process itself: the base image version through a variable in FROM, compiler flags, choosing between a build variant such as debug or release, or temporary values only needed during RUN commands that play no further role afterward. These values do not need to be visible in the finished image, their job ends with the last build step that references them.
ENV, on the other hand, is the right choice for anything the application genuinely needs at runtime: database hostnames, feature flags, log levels, timezone settings, or paths that the application itself reads out via environment variables. A good test for deciding: would the application, while running in the container, want to access this value via process.env or getenv()? If yes, it belongs in ENV, not ARG.
FROM php:8.4-fpm-alpine AS base
# ARG: only controls the build (which Composer dependencies)
ARG APP_ENV=production
RUN if [ "$APP_ENV" = "production" ]; then \
composer install --no-dev --optimize-autoloader; \
else \
composer install; \
fi
# ENV: read by the application at runtime
ENV APP_ENV=${APP_ENV}
ENV TZ=Europe/Berlin
ENV LOG_LEVEL=info
CMD ["php-fpm", "--nodaemonize"]
8. ARG in docker-compose.yml: the build.args section
Anyone building images not directly via docker build but through docker compose build or docker compose up --build sets ARG values not via the --build-arg CLI option, but via the args section inside build in the Compose file. These values are passed to the Dockerfile during the build exactly like a CLI flag and follow exactly the same visibility rules: after the build they are gone, unless explicitly carried over via ENV.
A common mistake is assuming that a value defined in environment in the Compose file is automatically also available as an ARG during the build. That is not the case: environment only sets runtime environment variables for the later container, entirely independent of the build process, while build.args only affects build time values. Both mechanisms are strictly separate and both must be maintained if a value is needed both while building and at runtime.
# docker-compose.yml
services:
app:
build:
context: .
args:
BUILD_VERSION: "2.4.1" # equivalent to --build-arg BUILD_VERSION=2.4.1
environment:
APP_ENV: production # only visible at runtime, independent of the build
image: mironsoft/app:2.4.1
9. More details on scoping behavior
One special case concerns the ARG line before the very first FROM instruction: it can be referenced inside that FROM line itself, for example to dynamically choose the base image version, but it no longer automatically counts as declared after the FROM. To use it inside the stage, as shown earlier, it has to be listed again there via ARG without a new default value, so the previously supplied value is carried over.
ENV variables, once set, are in turn inherited by all subsequent instructions within the same stage, including further RUN commands, and also override any same named variable previously set via ARG for the rest of the stage. It is also important that every ENV instruction creates its own image layer, permanently and immutably baking the value into the image metadata, visible to anyone who later runs docker inspect on the image.
# Inspect the ENV values of a finished image
docker inspect mironsoft/app:latest --format '{{.Config.Env}}'
# Read ARG values out of the build history (warning: secrets too!)
docker history --no-trunc mironsoft/app:latest | grep ARG
| Aspect | ARG | ENV |
|---|---|---|
| Visible during the build | Yes | Yes |
| Visible in the running container | No, disappears after the build | Yes, permanently as an environment variable |
| Settable from outside | Yes, via --build-arg | No, only in the Dockerfile or via docker run -e (overrides) |
| Valid across multi-stage boundaries | No, must be redeclared per stage | No, must be reset per stage |
| Suitable for secrets | No, ends up in the build history | No, ends up permanently in the image |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
ARG vs. ENV: Key Takeaways
ARG = build time
Only visible during docker build, disappears completely from the finished image.
ENV = runtime
Becomes a permanent part of the image metadata and is visible in every started container.
Multi-stage boundaries
Both must be redeclared or reset within each individual build stage.
Never secrets via ARG
Use BuildKit secret mounts instead of ARG to avoid baking credentials into the history.