Dockerfile RUN

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

RUN executes at build time and commits a layer. Shell vs exec form, why cleanup must share the layer, BuildKit cache mounts and pipefail.

RUN executes a command during the build and commits the result as a layer. It installs packages, compiles code, creates users and sets permissions. How you write it is most of the difference between a 50 MB image and a 500 MB one.

The single fact that explains nearly every RUN mistake: layers are additive and immutable. Anything a RUN creates is permanent, and a later RUN that deletes it only hides it.

Shell form and exec form

# Shell form. Runs via /bin/sh -c "..."
RUN apt-get update && apt-get install -y curl

# Exec form. Runs the binary directly, no shell
RUN ["apt-get", "install", "-y", "curl"]

Shell form is the normal choice for RUN, because &&, pipes, redirects and variable expansion all need a shell. Exec form matters when the base image has no shell at all.

Variable expansion is the trap:

ENV APP_HOME=/app

RUN ["mkdir", "-p", "$APP_HOME"]     # creates a directory literally named $APP_HOME
RUN mkdir -p $APP_HOME               # correct
RUN ["sh", "-c", "mkdir -p $APP_HOME"]  # also correct

(This is the opposite of the advice for CMD and ENTRYPOINT, where exec form is what you want so the process receives signals.)

Installing packages

Debian / Ubuntu:

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      curl \
      git \
      ca-certificates \
 && rm -rf /var/lib/apt/lists/*

Three non-negotiables here:

  • update and install in the same RUN. Separated, Docker caches the update layer by its command string and happily reuses a months-old package index against a fresh install. The failure looks like E: Version '1.2.3' for 'curl' was not found.
  • --no-install-recommends routinely saves tens of megabytes of packages nothing asked for.
  • Delete the lists in the same instruction, for the reason below.

If an install hangs waiting for input, you have hit a package with an interactive prompt:

ENV DEBIAN_FRONTEND=noninteractive

Prefer setting it inline on the one RUN that needs it, as a persistent ENV it also affects anyone who later docker execs into the container.

Alpine:

RUN apk add --no-cache curl git ca-certificates

--no-cache skips storing the index entirely, so there is no cleanup step.

RHEL / Fedora:

RUN dnf install -y curl git && dnf clean all

Why cleanup must be in the same layer

# Wrong. The apt lists are permanently in layer 1
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Right. Created and removed inside one layer
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

A layer is a filesystem diff. Deleting a file in a later layer records a whiteout entry. The file disappears from the container's view while still occupying space in the layer below, and still being readable by anyone who pulls the image and inspects it layer by layer.

That last part matters for more than size. A secret written and then deleted in a later RUN is still extractable from the image.

Find the layer that did it

docker history myapp:1.0 --no-trunc --format '{{.Size}}\t{{.CreatedBy}}'
0B       CMD ["node" "server.js"]
1.31MB   COPY . . # buildkit
178MB    RUN apt-get update && apt-get install -y build-essential
4.09MB   COPY package*.json ./
77.8MB   FROM node:22-alpine

The fat instruction names itself. Here build-essential is 178 MB that almost certainly belongs in a build stage rather than the shipped image.

Cache mounts make rebuilds fast

The upgrade most Dockerfiles are missing. A BuildKit cache mount keeps a package or compiler cache on the builder between builds, without putting a single byte in the image:

# syntax=docker/dockerfile:1

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends curl

Note that with a cache mount you deliberately stop deleting the lists. The mount is not part of the image, so there is nothing to clean up.

The same pattern pays off everywhere dependency resolution is slow:

# npm
RUN --mount=type=cache,target=/root/.npm npm ci

# pip
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# Go
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    go build -o /out/server .

A cold install that took 90 seconds typically drops to a few. The caveat is that the cache lives on whichever builder ran the build, so CI needs a persistent or exported cache to see the benefit.

Secrets belong in a mount, not a layer

RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
docker build --secret id=npmtoken,src=./token.txt -t myapp .

The value exists for the duration of that one instruction and is written to no layer. Passing the same token as an ARG records it in docker history for anyone who pulls the image.

Pipes hide failures

Shell form runs under /bin/sh -c, and in a pipeline only the last command's exit status counts. This build succeeds even when the download 404s:

RUN curl -fsSL https://example.com/install.sh | sh

curl fails, sh receives empty input and exits 0, the layer commits, and the missing tool surfaces at runtime instead. Fix it for the whole file:

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -fsSL https://example.com/install.sh | sh

On Alpine, where bash may be absent, do it per instruction:

RUN set -o pipefail && curl -fsSL https://example.com/x.sh | sh

Heredocs for multi-command blocks

# syntax=docker/dockerfile:1
RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF

Much easier to read than a wall of && \, and set -eux gives you fail-fast behaviour plus an echo of each command in the build log. Requires BuildKit, which is the default in current Docker.

Creating users and directories

# Debian
RUN groupadd -r appgroup \
 && useradd -r -g appgroup -d /app -s /sbin/nologin appuser

# Alpine
RUN addgroup -S appgroup \
 && adduser -S -G appgroup appuser

RUN mkdir -p /app/logs && chown -R appuser:appgroup /app

Prefer COPY --chown over a trailing RUN chown -R wherever you can, chown on copied files rewrites every one of them into a new layer.

Exercise

Write an Efficient RUN Instruction

You want a single RUN instruction in a Dockerfile based on ubuntu:24.04 that updates apt, installs curl and wget without recommended packages, and cleans up the package lists afterward - all in one layer. Which instruction is correct?

Cache-friendly ordering

RUN layers are cached on the instruction string, not on what the command produces. Order from least to most frequently changing:

FROM python:3.12-slim

# System packages. Change rarely
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Changes only when requirements.txt does
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Changes on every commit
COPY . .
CMD ["python", "app.py"]

When you genuinely need to bust the cache:

docker build --no-cache -t myapp .
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

Common mistakes

MistakeConsequence
update and install in separate RUNsStale index; install fails or pulls old versions
Cleanup in a later RUNFiles stay in the earlier layer; size unchanged
A secret written then deletedStill extractable from the image
curl … | sh without pipefailBuild succeeds on a failed download
ARG for a tokenRecorded in docker history
Build tools in the final imageHundreds of MB and extra attack surface
Exec form with $VARNo expansion; a literal $VAR directory
RUN chown -R after COPYEvery file duplicated into a new layer

Quick reference

TaskInstruction
Install (Debian)RUN apt-get update && apt-get install -y --no-install-recommends <pkg> && rm -rf /var/lib/apt/lists/*
Install (Alpine)RUN apk add --no-cache <pkg>
Cache dependenciesRUN --mount=type=cache,target=/root/.npm npm ci
Use a secretRUN --mount=type=secret,id=<id> …
Fail on pipe errorsSHELL ["/bin/bash", "-o", "pipefail", "-c"]
Multi-line blockRUN <<EOF … EOF
Find the fat layerdocker history <image> --no-trunc

Key takeaways

  • RUN runs at build time and commits a layer. CMD runs at container start and commits nothing.
  • Clean up inside the same instruction. A later rm hides files; it does not remove them.
  • Anything a layer ever contained is recoverable: which is why deleted secrets are still leaked secrets.
  • apt-get update must share its RUN with install, or a cached index goes stale.
  • Cache mounts speed up rebuilds without growing the image, and are the biggest easy win available.
  • Shell form swallows pipeline failures. Set pipefail before piping into a shell.
  • docker history tells you which instruction cost the megabytes: start there, not with guesses.

Check your understanding

Exercise

A cleanup that changes nothing

A Dockerfile downloads a 400 MB SDK in one RUN and deletes it in the next RUN. The final image is still around 400 MB larger than expected. Why?


Frequently Asked Questions

What is the difference between RUN and CMD in a Dockerfile?

RUN executes during docker build and commits the result as an image layer, so its effects are baked in permanently. This is where you install packages and compile code. CMD sets the default command executed when a container starts from the finished image, and it produces no layer content. A Dockerfile normally has several RUN instructions and exactly one CMD. If you find yourself wanting to "run something when the container starts", that is CMD or ENTRYPOINT, not RUN.

Why doesn't deleting files in a later RUN reduce my image size?

Because each instruction commits an immutable filesystem diff. A delete in a later layer writes a whiteout marker, which makes the file invisible inside the container while the actual bytes stay in the layer that created them and still travel with the image. Put the creation and the deletion in one RUN joined with &&, or move the whole step into a build stage that never ships. The same mechanism is why a secret written and later deleted is still recoverable from the published image.

Why should apt-get update and apt-get install be in the same RUN?

Docker caches a RUN layer on its command string, not on what the command actually produced. As a separate instruction, RUN apt-get update keeps its cached layer indefinitely, so a later install runs against a package index that may be months old. Producing Version '…' for '…' was not found or silently installing outdated packages. Chaining them means any change to the install list re-runs the update too.

How do I make Docker builds faster without making the image bigger?

Use BuildKit cache mounts. RUN --mount=type=cache,target=/root/.npm npm ci keeps the package cache on the builder between builds while writing nothing into the image, and the same works for pip, Go module and build caches, and apt. Combine that with ordering instructions from least to most frequently changing, and copying dependency manifests before source code. In CI you will also need a persistent or exported cache, since the mount lives on whichever builder ran the build.

Why does my build succeed even though a command inside RUN failed?

Shell form runs under /bin/sh -c, and in a pipeline only the final command's exit status is reported. So RUN curl -fsSL https://… | sh succeeds even when curl 404s, because sh reads empty input and exits cleanly. Add SHELL ["/bin/bash", "-o", "pipefail", "-c"] near the top of the Dockerfile, or prefix the individual instruction with set -o pipefail &&. Using set -eux inside a heredoc block gives the same protection plus a readable build log.

How do I find which instruction made my image so large?

docker history <image> --no-trunc lists every layer with its size and the instruction that created it, largest offenders usually being obvious at a glance. docker image inspect and tools like dive go further if you need to see individual files. Once you have identified it, the fix is normally one of three things: clean up inside the same RUN, add --no-install-recommends, or move the whole step into a build stage and copy only the artefact forward with COPY --from.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources