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?
answer
- Shell form → /bin/sh -c wrapper
- Exec form → JSON array, double quotes, no shell
- No $VAR, no glob, no pipe in exec form
- Pipeline exit code = last command → pipefail
- SHELL changes shell form only
basics
~20 sThe 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 linesENV 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 /optgo deeper
Know that the string form goes through a shell (so &&, variables and pipes work) and the JSON array form runs the binary directly.
Add JSON strictness, why exec form is required when no shell exists, and that neither form changes layer count; mention SHELL.
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.
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