skip to content

The RUN instruction in a Dockerfile can be written as `RUN apt-get update` or as `RUN ["apt-get", "update"]`. What is the difference between these two forms, and when does the choice actually change the outcome?

level: middleimportance: should knowfreq 50%

answer

  1. Shell form → /bin/sh -c wrapper
  2. Exec form → JSON array, double quotes, no shell
  3. No $VAR, no glob, no pipe in exec form
  4. Pipeline exit code = last command → pipefail
  5. SHELL changes shell form only

basics

~20 s

The string form is shell form: Docker runs it via /bin/sh -c, so variables, globs, pipes and && work. The JSON-array form is exec form: the binary is executed directly with no shell, so shell syntax is literal and the image needs no shell.

solid answer

~50 s

`RUN <command>` is **shell form**: Docker wraps it as `/bin/sh -c "<command>"`. The shell performs variable expansion, globbing, pipes, redirection and `&&` chaining. That is why almost all real Dockerfiles use it. `RUN ["executable", "arg"]` is **exec form** (valid JSON, double quotes required). The executable is invoked directly with those exact arguments — no shell, therefore no `$VAR` expansion, no `*`, no `|`, no `&&`. `RUN ["echo", "$HOME"]` prints the literal `$HOME`. When the choice matters: - The stage has **no shell** (scratch/distroless): only exec form can run anything. - Arguments contain characters a shell would mangle, or you want no word-splitting surprises. - **Exit codes in pipelines**: in shell form, `RUN a | b` reports only `b`'s status, so a failing `a` doesn't fail the build. Fix with `SHELL ["/bin/bash","-o","pipefail","-c"]`. The `SHELL` instruction changes which interpreter shell form uses; exec form ignores it.

code

dockerfile · 11 lines
dockerfile
ENV VERSION=1.4.0

# shell form: expands VERSION, chains, globs
RUN echo "building $VERSION" && rm -f /tmp/*.log

# exec form: no shell -> prints the literal $VERSION
RUN ["echo", "$VERSION"]

# without pipefail this RUN succeeds even if curl fails
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -fsSL https://example.com/x.tgz | tar -xz -C /opt

go deeper

for a junior

Know that the string form goes through a shell (so &&, variables and pipes work) and the JSON array form runs the binary directly.

for a middle

Add JSON strictness, why exec form is required when no shell exists, and that neither form changes layer count; mention SHELL.

for a senior

Lead with the pipefail failure mode and its fix, plus fresh-container semantics that make RUN cd/export useless, and argument-injection safety when ARG values feed a command.

for a principal

Frame it as build-reliability policy — pipefail by default in base Dockerfile templates, lint rules that catch silent pipeline failures, and shell-free runtime stages as a hardening standard.

## The two forms Several Dockerfile instructions accept both a plain-string and a JSON-array form. For `RUN`: - **Shell form**: `RUN apt-get update && apt-get install -y curl`. Docker executes this as `/bin/sh -c "apt-get update && apt-get install -y curl"` on Linux (`cmd /S /C` on Windows). - **Exec form**: `RUN ["apt-get", "install", "-y", "curl"]`. Docker execs the binary directly with the given argv. It must be valid JSON: double quotes, no trailing comma, backslashes escaped (`"C:\\tmp"`). A single-quoted "array" is not JSON — Docker treats the whole thing as shell form, a subtle and common bug. ## What the shell gives you Everything people take for granted is a shell feature, not a Docker feature: - **Variable expansion** of `ENV` and `ARG` values: `RUN echo $VERSION` works in shell form; in exec form `$VERSION` is a literal string. - **Globbing**: `RUN rm -rf /tmp/*.log`. - **Pipes and redirection**: `RUN curl -s url | tar -xz`. - **Chaining**: `&&`, `||`, `;` — the basis of keeping many commands in one layer. - **Line continuation** with `\`, letting a long install read as a block. Since controlling layer count depends on `&&`, shell form is the default for RUN in practice, and exec form for RUN is comparatively rare. ## What exec form gives you - **No shell required in the image.** In a `scratch` or distroless stage there is no `/bin/sh`, so shell form fails outright with something like `exec: "/bin/sh": stat /bin/sh: no such file or directory`. Exec form is the only option. - **No shell interpretation.** Arguments containing spaces, quotes, `$` or wildcards are passed through exactly. This matters when arguments come from `ARG` values you do not control. - **One less process.** Trivial for RUN (the layer is committed either way), but the same distinction matters much more for the container's entry process, where an intervening shell affects signal delivery and PID 1 semantics — a topic in its own right. ## Layers are unaffected by the form Each `RUN`, regardless of form, executes in a temporary container from the previous layer and commits the resulting filesystem diff as a new layer. Two `RUN`s make two layers; one `RUN` with `&&` makes one. The form changes *how the command is interpreted*, never how many layers appear. Similarly, each `RUN` starts fresh: `RUN cd /app` has no effect on the next instruction (use `WORKDIR`), and `RUN export FOO=bar` does not persist (use `ENV`), because only the filesystem diff is committed — not the process's working directory or environment. ## The pipefail trap A POSIX shell reports the exit status of the **last** command in a pipeline. So: ``` RUN wget -qO- https://example.com/install.sh | sh ``` succeeds even if `wget` fails outright, because `sh` exited 0 reading empty input. The build proceeds and produces a broken image. Two fixes: ``` SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN wget -qO- https://example.com/x | tar -xz -C /opt ``` or avoid the pipeline: download to a file, check it, then process it. Note `pipefail` is a bash/zsh feature, not POSIX `sh`, so the `SHELL` change requires bash to exist in the image — Alpine's default `sh` (BusyBox) does not support it, though BusyBox ash does accept `set -o pipefail` in recent versions. Interviewers like this one because it explains real "the build was green but the image was broken" incidents. ## The SHELL instruction `SHELL` changes the interpreter and flags used by *shell form* for all subsequent instructions in that stage. It is how you enable `pipefail`, switch to bash for `[[ ]]`, or target PowerShell on Windows. It has no effect on exec form, which is precisely the point: exec form is the escape hatch from shell semantics. ## Interview-ready summary Shell form for RUN by default, because chaining with `&&` and expanding variables is the job. Exec form when there is no shell or when arguments must not be reinterpreted. Add `SHELL ["/bin/bash","-o","pipefail","-c"]` in any stage whose RUNs use pipes, and remember that neither form changes layer creation.

  • Why does `RUN cd /app` followed by `RUN npm install` not install in /app?
    Every RUN executes in a fresh container started from the previous layer, and only the filesystem diff is committed — not the process's working directory or environment. The `cd` affects only that one shell process, which exits immediately. Use `WORKDIR /app`, which sets the directory for all subsequent instructions and persists in the image config. The same reasoning explains why `RUN export FOO=bar` doesn't persist and `ENV` is required.
  • Your image is built FROM scratch and `RUN echo hi > /marker` fails with a message about /bin/sh. What is happening?
    Shell form wraps the command as `/bin/sh -c`, and a scratch image contains no shell at all, so the exec fails before your command runs. Exec form would invoke a binary directly — but scratch has no `echo` binary either, so in practice you produce such files in a builder stage that does have a userland and copy them in with COPY --from.

saying these in an interview costs you the question

  • Expecting $VAR or wildcards to expand inside a JSON exec-form RUN
  • Thinking exec form produces fewer layers than shell form
  • Writing the array with single quotes and assuming it is exec form
  • Believing a failing command in a pipeline always fails the build
  • Using RUN cd or RUN export and expecting the effect to persist

context