RUN
Execute commands at build time. Learn shell vs exec form, how to keep layers lean, combine commands efficiently, and avoid bloated images.
RUN
RUN executes a command during the build phase and commits the result as a new image layer. It's how you install packages, compile code, create directories, set permissions, and do any other build-time work. Using it well is the difference between a 50MB image and a 500MB one.
Basic Syntax
# Shell form - runs in /bin/sh -c "..."
RUN apt-get update && apt-get install -y curl
# Exec form - runs directly, no shell
RUN ["apt-get", "install", "-y", "curl"]
The shell form is more common for RUN because you need shell features: &&, ||, pipes (|), redirects (>), variable expansion.
The exec form avoids the shell overhead and is useful when the base image has no shell (e.g. FROM scratch).
Installing Packages
Debian / Ubuntu (apt-get)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
Always:
- Combine
updateandinstallin oneRUN- running them separately caches the update and leads to stale package lists - Use
--no-install-recommendsto avoid pulling in unnecessary suggested packages - Clean up the package cache (
rm -rf /var/lib/apt/lists/*) in the same layer
Alpine (apk)
RUN apk add --no-cache curl git ca-certificates
--no-cache tells apk not to store the package index - no separate cleanup step needed.
RHEL / CentOS (dnf / yum)
RUN dnf install -y curl git && \
dnf clean all
Combining Commands
Every RUN is a separate layer. Combine related commands with && and \ for readability:
# 4 separate layers - cache issues, more image overhead
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
# One layer - clean, efficient
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git && \
rm -rf /var/lib/apt/lists/*
Why Cleanup Must Be in the Same Layer
This is a critical point that trips up many Dockerfiles:
# The cache directory is STILL in Layer 1 - Layer 2 can't remove it
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Added and removed in the same layer - truly gone
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Docker layers are immutable. If you add files in one RUN and delete them in a later RUN, the files still exist in the earlier layer - they're just hidden. The image size doesn't shrink.
Build and Cleanup in One Layer
For compiling from source or installing build tools you don't need at runtime:
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
make \
libssl-dev && \
./configure && make && make install && \
apt-get purge -y --auto-remove gcc make libssl-dev && \
rm -rf /var/lib/apt/lists/*
Or better - use a multi-stage build so build tools never enter the final image at all (see the Multi-Stage Builds section).
Cache-Friendly RUN Ordering
RUN instructions that change rarely should come before those that change often. Once a layer is invalidated, all subsequent layers rebuild.
FROM python:3.12-slim
# ── Rarely changes - near the top ──────────────
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ── Changes when requirements.txt changes ──────
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Changes frequently - at the bottom ─────────
COPY . .
CMD ["python", "app.py"]
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?
Running Scripts
# Copy and run a setup script
COPY scripts/setup.sh /tmp/setup.sh
RUN chmod +x /tmp/setup.sh && /tmp/setup.sh && rm /tmp/setup.sh
# Or inline with heredoc (BuildKit / Docker 23+)
RUN <<EOF
set -e
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF
Heredoc syntax (<<EOF) is cleaner for multi-command blocks but requires BuildKit (enabled by default in Docker 23+).
Creating Users and Directories
# Create a non-root user and group
RUN groupadd -r appgroup && \
useradd -r -g appgroup -d /app -s /sbin/nologin appuser
# Alpine equivalent
RUN addgroup -S appgroup && \
adduser -S -G appgroup appuser
# Create directories with correct ownership
RUN mkdir -p /app/logs /app/tmp && \
chown -R appuser:appgroup /app
Environment Variables in RUN
Shell form has access to environment variables set with ENV and ARG:
ARG NODE_VERSION=20
RUN echo "Installing Node $NODE_VERSION"
ENV APP_HOME=/app
RUN mkdir -p $APP_HOME && echo "Created $APP_HOME"
Exec form does NOT expand variables - use shell form or wrap with sh -c:
ENV APP_HOME=/app
# $APP_HOME is not expanded in exec form
RUN ["mkdir", "-p", "$APP_HOME"]
# Shell form expands it
RUN mkdir -p $APP_HOME
# Or explicit sh -c in exec form
RUN ["sh", "-c", "mkdir -p $APP_HOME"]
Caching RUN Instructions
Docker caches RUN layers based on the instruction string. If the string hasn't changed, the cached layer is reused - even if an external resource (like a package version) has changed.
To force a cache bust for package installs:
# Skip cache for this build
docker build --no-cache -t myapp .
# Or use a build arg to invalidate at a specific point
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .
In Dockerfile:
ARG CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y # Reruns when CACHE_BUST changes
Summary
RUNexecutes commands at build time - the result is baked into a layer- Combine related commands with
&&into a singleRUNto reduce layers - Always clean up (caches, temp files, build tools) in the same
RUNinstruction - Use
--no-install-recommends(apt) or--no-cache(apk) to keep installs lean - Shell form is standard for
RUN; exec form avoids the shell but doesn't expand variables