Skip to content
Blog

RUN Layer Hygiene and Command Chaining

How to structure RUN instructions — chain update with install, clean up in-layer, avoid over-chaining, and keep the build cache working for you.

Published on • October 18, 2026

AI Assistant

Every RUN instruction commits a new filesystem layer, and every RUN is a cacheable unit of execution. That single fact drives all of Dockerfile hygiene: too many small RUNs bloat the image and slow builds, one giant RUN busts your cache on every edit, and cleanup done in the wrong layer doesn’t shrink anything. The canonical source for these rules is Tibor Vass’s Intro Guide to Dockerfile Best Practices on the Docker blog, reinforced by the building best practices docs.

Chain apt-get update with apt-get install — always

This is the rule people break most often, and the docs spell out exactly why. Consider a Dockerfile that splits them:

# syntax=docker/dockerfile:1
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl

Now add nginx to the install line. The builder sees the first RUN apt-get update as unchanged, reuses its cached layer, and never re-runs the update — you install curl and nginx from a stale index, potentially getting outdated or missing packages. The fix is one instruction:

RUN apt-get update && apt-get install -y --no-install-recommends \
    package-bar \
    package-baz \
    package-foo

Update and install form one cacheable unit (blog tip #3): the index refresh is always fresh whenever the package list changes, which the docs call cache busting. The alternative, version pinning, forces the fetch regardless of cache:

RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    s3cmd=1.1.*

Clean up in the same layer

The package cache lives in /var/lib/apt/lists/. If you delete it in a separate RUN, you add a layer and subtract nothing — the bytes were already committed to the previous layer, and deleting files in a new layer just whiteouts them. Blog tip #5 is explicit: remove the package manager cache in the same RUN that installed the packages.

RUN apt-get update && apt-get install -y --no-install-recommends \
    aufs-tools \
    build-essential \
    curl \
    libcap-dev \
    s3cmd=1.1.* \
    && rm -rf /var/lib/apt/lists/*

Two footnotes from the docs: official Debian/Ubuntu images already run apt-get clean automatically (so don’t bother), and --no-install-recommends matters because apt otherwise installs recommended-but-unnecessary packages — blog tip #4 calls these out as pure footprint.

Don’t chain everything into one RUN

The opposite failure mode is one mega-instruction that compiles the OS, installs the toolchain, builds the app, and wipes the tmp dir. Blog tip #3 warns that chaining all commands into a single RUN busts the cache easily and hurts the development cycle: change a comment in your source, and the 10-minute dependency install runs again.

The balance:

  • Put rarely-changing steps early: base image, system packages, dependency manifests, dependency install.
  • Put frequently-changing steps last: COPY of source, build/test commands.
  • Avoid COPY . when a narrower copy exists (blog tip #2) — every unrelated file change invalidates the layer and everything after it.

Format chains so they stay readable

Long chains use backslash continuations, and the docs recommend sorting multi-line arguments alphanumerically (the pattern comes from the buildpack-deps image):

RUN apt-get update && apt-get install -y --no-install-recommends \
  bzr \
  cvs \
  git \
  mercurial \
  subversion \
  && rm -rf /var/lib/apt/lists/*

Sorted, deduplicated, one package per line — PRs that add a package show up as a single clean diff. Here-documents are the alternative when you’d rather not chain with && at all:

RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends package-bar package-baz package-foo
rm -rf /var/lib/apt/lists/*
EOF

Note that unlike &&, a here-doc won’t stop the build on the first failing command unless you add set -e — chaining with && makes each step’s failure fail the build, which is usually what you want.

Watch exit codes in pipes

RUN executes via /bin/sh -c, which only checks the last command’s exit status. This builds successfully even when the download fails:

RUN wget -O - https://some.site | wc -l > /number

Prepend set -o pipefail:

RUN set -o pipefail && wget -O - https://some.site | wc -l > /number

dash (the default sh on Debian/Ubuntu) doesn’t support pipefail, so pick a shell that does:

RUN ["/bin/bash", "-c", "set -o pipefail && wget -O - https://some.site | wc -l > /number"]

Move big work out of layers with mounts

Cache directories don’t belong in the layer at all. BuildKit mounts let a RUN use scratch space that is never committed:

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.gradle ./gradlew build
RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt

The bind mount is strictly better than a COPY + RUN + rm sequence: the file exists only for that instruction and never lands in the image. For credentials, --mount=type=secret injects them at build time without them entering a layer or the build args.

Shed build dependencies entirely

Even a perfectly chained install can leave compilers and toolchains in your final image. Blog tip #11’s recommended Dockerfile ends with a multi-stage build — a builder stage that owns mvn package (or npm ci, go build), and a final stage that receives only the artifact:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B -e dependency:go-offline
COPY src ./src
RUN mvn -B -e package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/target/app.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Dependency fetch sits between two COPY steps as its own cacheable unit (blog tip #10) — source edits never re-download dependencies — and the build stage’s layers never reach the final image.

Sources