skip to content

A Dockerfile declares `ARG APP_VERSION=1.0` on the very first line, before any FROM instruction, and a later `RUN echo $APP_VERSION` inside the build stage prints nothing. Why is it empty, and how do you fix it?

level: middleimportance: should knowfreq 45%

answer

  1. pre-FROM ARG = only for FROM lines
  2. stages get a fresh ARG scope
  3. re-declare bare: `ARG NAME` with no `=`
  4. undeclared reference = silent empty string
  5. TARGETARCH needs `ARG TARGETARCH` too

basics

~20 s

An ARG declared before the first FROM lives in a global scope usable only by FROM lines. Build stages do not inherit it. Re-declare ARG APP_VERSION (no value needed) inside the stage; the value passed or defaulted globally is then applied.

solid answer

~50 s

An `ARG` before the first `FROM` belongs to a **pre-stage/global scope**. It exists so you can parameterise the base image itself — `ARG NODE_VERSION=20` then `FROM node:${NODE_VERSION}` — and it is deliberately not inherited by the build stages, because each stage has its own ARG scope. The fix is to re-declare the name inside the stage, with no value: ```dockerfile ARG APP_VERSION=1.0 FROM alpine:3.20 ARG APP_VERSION # re-declare: inherits the global value RUN echo $APP_VERSION ``` The bare re-declaration pulls in whatever came from `--build-arg` or the global default. If you write `ARG APP_VERSION=` with an empty default, or misspell the name, you get an empty string silently — build args never error on being unset. The same rule applies stage to stage in a multi-stage build: every stage that uses a build arg must declare it.

code

dockerfile · 16 lines
dockerfile
ARG GO_VERSION=1.23
ARG APP_VERSION=0.0.0-dev

FROM golang:${GO_VERSION}-alpine AS build
ARG APP_VERSION
ARG TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOARCH=${TARGETARCH} \
    go build -ldflags "-X main.version=${APP_VERSION}" -o /out/app ./cmd/app

FROM gcr.io/distroless/static
ARG APP_VERSION
LABEL org.opencontainers.image.version="${APP_VERSION}"
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

go deeper

for a junior

Know the symptom and the one-line fix: re-declare ARG NAME inside the stage.

for a middle

Explain global versus per-stage scope, why the pre-FROM scope exists (parameterising FROM), and that unresolved references are silently empty.

for a senior

Connect it to cache behaviour and to multi-arch builds with TARGETARCH, and verify the contract with a label or an inspect check rather than trusting the build.

for a principal

Standardise the pattern across the org's Dockerfiles — declared args at the top, re-declared per stage, version stamped into an OCI label — so provenance is verifiable on every image.

## Two scopes, deliberately separated A Dockerfile has a **global (pre-FROM) ARG scope** and one **stage-local ARG scope per build stage**. ARGs declared before the first `FROM` exist so the base image can be parameterised: ```dockerfile ARG NODE_VERSION=20 ARG REGISTRY=docker.io/library FROM ${REGISTRY}/node:${NODE_VERSION}-alpine ``` That is their designed purpose. Once a `FROM` opens a stage, the builder starts a fresh ARG scope for that stage. Global ARGs are *not* automatically visible inside it. So `RUN echo $APP_VERSION` substitutes an undeclared variable, which the builder replaces with the empty string — and, importantly, does not warn loudly about, so the build succeeds with a wrong value baked in. ## The fix: bare re-declaration ```dockerfile ARG APP_VERSION=1.0 # global: available to FROM lines FROM alpine:3.20 ARG APP_VERSION # stage-local: no '=' — inherits the global/CLI value RUN echo "building $APP_VERSION" ``` Writing `ARG APP_VERSION` with **no default** is the key detail. It says "this stage uses this build arg", and the value resolution then walks: `--build-arg` on the CLI → the global default declared before FROM → empty. If you instead write `ARG APP_VERSION=fallback`, that local default only applies when nothing was supplied at all; a `--build-arg` still wins. ## Multi-stage: declare in every stage that uses it Stage scopes are independent of each other too, not just of the global scope: ```dockerfile ARG GO_VERSION=1.23 FROM golang:${GO_VERSION} AS build ARG TARGETARCH ARG APP_VERSION RUN CGO_ENABLED=0 GOARCH=$TARGETARCH go build -ldflags "-X main.version=$APP_VERSION" -o /app . FROM gcr.io/distroless/static AS runtime ARG APP_VERSION # declare again — the build stage's scope does not carry over LABEL org.opencontainers.image.version=$APP_VERSION COPY --from=build /app /app ``` A missing re-declaration in the second stage produces an empty label, not an error. This silent-empty behaviour is exactly why the question is asked: it is a real bug class, and it usually surfaces as a version string of `unknown` in production. ## Interaction with caching Build args participate in the layer cache only for instructions that actually reference them. Changing `--build-arg APP_VERSION=...` invalidates the `RUN` steps whose command text (or environment) includes it, and everything after. A common optimisation is therefore to place a frequently-changing ARG (a version stamp, a git SHA) as late as possible, after dependency installation, so a new version number does not bust the expensive layers. Conversely, an ARG declared but never referenced does not affect the cache. ## Undeclared args and warnings Passing `--build-arg FOO=bar` when the Dockerfile never declares `FOO` produces a warning (`[Warning] One or more build-args were not consumed`) and no effect. Conversely, *referencing* an undeclared variable produces an empty substitution. Both failure directions are quiet, so a build-arg contract should be verified rather than assumed: echo the value in a `RUN` during development, or set a `LABEL` from it and check with `docker inspect`. ## Predefined global args BuildKit predeclares platform args that follow the same re-declaration rule: `TARGETPLATFORM`, `TARGETOS`, `TARGETARCH`, `TARGETVARIANT`, `BUILDPLATFORM`, `BUILDOS`, `BUILDARCH`. They are available to `FROM` lines automatically, and inside a stage only after `ARG TARGETARCH` (etc.). This is the single most common place people meet the rule, when writing a cross-compiling multi-arch build. The proxy args (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, …) are also predeclared, and additionally are stripped from `docker history`. ## How to remember it Global ARG = "how do I pick the base image". Stage ARG = "what does this stage need to know". If both, name it once at the top and re-declare it in every stage that uses it. And when a variable mysteriously interpolates to nothing, the first thing to check is whether an `ARG` line for it exists *inside the current stage, above the line that uses it*.

  • What happens if you pass `--build-arg FOO=bar` but no ARG named FOO is declared anywhere in the Dockerfile?
    The build succeeds and the value is simply unused; Docker prints a warning that one or more build args were not consumed. Nothing fails, which is why a mistyped build-arg name can go unnoticed for a long time. The mirror-image mistake — referencing `$FOO` without an `ARG FOO` in scope — substitutes an empty string just as quietly.
  • In a multi-stage build, does re-declaring `ARG APP_VERSION` in the second stage require passing `--build-arg` twice?
    No. The value is supplied once on the command line (or by the global default) and the bare `ARG NAME` declaration in each stage opts that stage into seeing it. The repetition is about scope declaration, not about re-supplying the value.
  • Why place a git-SHA build arg late in the Dockerfile?
    Because a build arg only invalidates cache for instructions that reference it and everything after them. If the SHA is referenced before dependency installation, every build with a new commit re-runs the expensive install layers. Declaring and using it after the dependency steps keeps those layers cached and rebuilds only the final, cheap ones.

The global ARG is written on the front door so you can choose which building to enter; once inside a room you have to write the note down again for anyone in that room to read it.

saying these in an interview costs you the question

  • Assuming a pre-FROM ARG is automatically visible inside build stages.
  • Writing `ARG NAME=` with an empty default inside the stage, which pins it to empty instead of inheriting.
  • Expecting an error when a referenced variable is undeclared — it silently becomes an empty string.
  • Declaring build args once and assuming multi-stage builds share one ARG scope.
  • Using `$TARGETARCH` in a RUN without an `ARG TARGETARCH` line in that stage.

context