ARG vs. ENV in the Dockerfile, Properly Explained
AI generated
FROM
RUN
Docker · Dockerfile · Build System
ARG vs. ENV in the Dockerfile
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.

17 min read ARG ENV Dockerfile multi-stage build build secrets

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/, is visible only to that one command, and is never permanently stored in the image history or in any layer. Once the RUN instruction finishes, the secret value is gone completely.

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.

11. FAQ: ARG vs. ENV: Key Takeaways

1Can I read an ARG value in the running container via process.env?
No, ARG values exist only during the build process and do not automatically become part of the runtime environment. To make a value available in the container as well, it must be explicitly assigned to ENV using the ARG value.
2Why is an ARG variable declared globally before FROM suddenly empty in a later stage?
Because Docker scopes ARG declarations per stage. A global ARG line before the first FROM instruction makes the variable potentially available in every stage, but it must be referenced again via ARG within each stage, otherwise it stays empty there.
3Is it safe to pass a database password via --build-arg?
No. The value ends up in the image history by default and can be read out with docker history --no-trunc, and it frequently also ends up in shell history and CI/CD logs. For secrets, BuildKit secret mounts with --mount=type=secret should be used instead.
4What happens if I use an ARG variable with no default value and no --build-arg?
The variable is then empty during the build, which leads to an empty string substitution in RUN instructions without Docker automatically throwing an error. This can cause subtle bugs, such as empty configuration values in generated files.
5Can I override an ENV value at docker run time?
Yes, with docker run -e VARIABLE_NAME=newValue a value set via ENV in the image can be overridden at runtime. The Dockerfile default only applies as long as no explicit environment variable is passed at start.
6Do I need to redeclare ARG in every build stage of a multi-stage build?
Yes, that is one of the most common pitfalls. Every stage needs its own ARG line to make a previously globally declared variable usable, even if the same variable was already declared before the first FROM line or in an earlier stage.
7Are ENV values automatically copied from one stage to the next?
No, ENV values only apply within the stage where they were set. For a later stage, they must be explicitly defined again there via ENV, a COPY --from only transfers files, not environment variables.
8What is ARG best suited for if not runtime configuration?
For anything that solely controls the build process: base image versions in FROM, choosing between build variants like debug and release, compiler or optimization flags, or temporary values that play no further role after the last referencing RUN command.
9Are there predefined ARG variables I don't need to declare myself?
Yes, Docker automatically supports variables like HTTP_PROXY, HTTPS_PROXY, and NO_PROXY for build environments behind a proxy. Custom variable names, however, must always be explicitly declared in the Dockerfile via ARG.
10How can I check afterward which ENV values are set in a finished image?
With docker inspect --format '{{.Config.Env}}' you can view all environment variables permanently baked into the image. That is also a good way to spot sensitive values accidentally set via ENV instead of a BuildKit secret.