Dockerfile Key Instructions and Best Practices

A guide to essential Dockerfile instructions including FROM, COPY, RUN, CMD, and ENTRYPOINT, plus practical optimization techniques: the layer-cache model, multi-stage builds, and running as a non-root user.

A Dockerfile is a text file for automatically building Docker images. The instructions written in a Dockerfile are executed sequentially from top to bottom, ultimately creating a Docker image. Here, we explain the major instructions commonly used in Dockerfiles, and then go further into how the order of those instructions affects build speed and image size — the layer-cache model, multi-stage builds, and running as a non-root user — the optimization points that matter once you’re writing Dockerfiles for real projects.

FROM

Specifies the base image. This must be the first instruction in the Dockerfile.

FROM <image_name>[:<tag>]
# Example: FROM ubuntu:22.04
# Example: FROM python:3.9-slim-buster

LABEL

Specifies image metadata. Written as key-value pairs, it can attach information about the image (author, version, description, etc.).

LABEL <key>="<value>" [<key>="<value>" ...]
# Example: LABEL maintainer="Your Name <your.email@example.com>"
# Example: LABEL version="1.0" description="My custom web app image"

USER

Specifies the user under which subsequent RUN, CMD, and ENTRYPOINT instructions are executed. The default is root. For security, it is recommended to specify a non-root user for operations that do not require privileges.

USER <username>[:<groupname>]
# Example: USER appuser

WORKDIR

Specifies the working directory for subsequent RUN, CMD, ENTRYPOINT, COPY, and ADD instructions. If specified as a relative path, it is relative to the previous WORKDIR instruction.

WORKDIR /path/to/workdir
# Example: WORKDIR /app

EXPOSE

Specifies the ports the container listens on. This serves as documentation; the docker run -p option is required to actually publish ports.

EXPOSE <port_number> [<port_number>/<protocol> ...]
# Example: EXPOSE 80
# Example: EXPOSE 8080/tcp 8080/udp

COPY

Copies files or directories from the host machine (the machine running the Docker daemon) into the container image.

COPY <host_path> <container_path>
# Example: COPY ./app /app
# Example: COPY requirements.txt /tmp/

ADD

Similar to COPY, but with the following additional features:

  • If host_path is a URL, it downloads the file and places it in the container.
  • If host_path is a compressed file (tar, gzip, bzip2, etc.), it is automatically extracted in the container.
ADD <host_path_or_URL> <container_path>
# Example: ADD https://example.com/app.tar.gz /app/

Generally, COPY is preferred. Use ADD only when its additional features are needed.

RUN

Executes commands inside the container during image build time. This allows installing software and setting up files in the image.

RUN <command>
# Example: RUN apt-get update && apt-get install -y nginx
# Example: RUN pip install -r requirements.txt

Chaining multiple commands with && in a single RUN instruction reduces the number of image layers and makes efficient use of the build cache.

CMD

Specifies the default command to execute when the container starts. Only one can be specified per Dockerfile. If multiple are present, the last one takes effect.

CMD ["executable", "arg1", "arg2"] # exec form (recommended)
# Example: CMD ["nginx", "-g", "daemon off;"]
# Example: CMD ["python", "app.py"]

CMD command param1 param2 # shell form
# Example: CMD python app.py

In shell form, the command is executed with /bin/sh -c.

ENTRYPOINT

Specifies the command to execute when the container starts. Unlike CMD, ENTRYPOINT is always executed, and CMD is treated as arguments to ENTRYPOINT.

ENTRYPOINT ["executable", "arg1"] # exec form (recommended)
# Example: ENTRYPOINT ["/usr/sbin/nginx"]

ENTRYPOINT command param1 param2 # shell form
# Example: ENTRYPOINT nginx -g "daemon off;"

Combining ENTRYPOINT and CMD allows defining flexible container startup commands. For example, with ENTRYPOINT ["nginx"] and CMD ["-g", "daemon off;"], the container runs nginx -g "daemon off;" at startup. Specifying arguments with docker run overrides the CMD content.

So far we’ve covered the basic usage of each instruction. The rest of this article goes into the practical optimization points you need once you’re actually writing Dockerfiles for real projects.

A note on the environment this article was written in: The environment used to write this article does not have docker installed (docker --version fails with a command-not-found error), so it was not possible to start a Docker daemon and actually build the images described below. Everything about build behavior in the sections that follow is therefore not a measured log, but a walkthrough based on the documented semantics in the Dockerfile reference and the official build cache documentation . Anywhere content is based on documented semantics rather than a measurement, it is called out explicitly.

The layer-cache model

Every instruction in a Dockerfile adds a new layer to the image each time it runs. A layer is a read-only filesystem diff; layers are applied on top of one another as instructions execute top to bottom, and the final image is the union of all layers.

What determines the cache key, and what invalidates it

Before executing each instruction, docker build checks, layer by layer, whether this instruction has been run before with the same input. If so, it skips execution and reuses the existing layer. This is the build cache. What counts as “the same input” depends on the kind of instruction:

  • For instructions like RUN that take a command string as their argument, the cache is keyed on the instruction string itself (the command and its arguments) matching the previous build exactly.
  • For instructions like COPY and ADD that pull files from the host into the image, the cache key also includes a checksum of the copied files’ content and metadata, in addition to the instruction string. If even a single byte of a referenced file changes, the cache for that COPY/ADD instruction is invalidated.

The most important property here is that once one layer’s cache is invalidated, every layer after it in the Dockerfile is invalidated too — the invalidation cascades downstream. BuildKit treats layers as a dependency chain ordered by the Dockerfile’s instructions, so once the input to a layer partway through changes, the very premise for asking “is the input the same as last time?” breaks down for everything after it, and those layers are unconditionally re-run. Even if re-running would produce byte-for-byte identical output, Docker can’t know that without actually running the command, so it can’t be skipped.

Why “install dependencies first, copy application code later” is the standard optimization

This property is exactly why the standard practice is to COPY a dependency manifest (package.json, requirements.txt, go.mod, etc.) and install dependencies before copying the rest of the application code.

# Optimized order
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]

Application code changes far more often than the dependency manifest does. With this ordering, as long as package.json doesn’t change, the COPY package.json ... and RUN npm install layers are reused from cache, and the build avoids re-running npm install — a relatively expensive step involving network I/O and disk writes — on every build. Compare that to the following naive ordering, where this optimization doesn’t apply:

# Naive order (unoptimized)
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Because COPY . . copies the entire application (including package.json) in one shot, changing even a single line of source code invalidates the COPY . . cache — and, by the cascade rule above, the RUN npm install layer right after it as well. This happens even when not a single dependency actually changed.

Verification: how instruction order changes cache efficiency

Let’s trace, instruction by instruction, what happens to the two Dockerfiles above (naive order / optimized order) when only the contents of app.js are changed by one line and docker build is run again. (As noted above, Docker isn’t available in this environment, so what follows is a trace based on the documented semantics rather than a measured run.)

Diagram showing how Dockerfile instruction order affects layer-cache propagation. On the left, the naive order (FROM → COPY . . → RUN npm install → CMD) — changing only app.js causes every layer from COPY . . onward to miss cache and re-run. On the right, the optimized order (FROM → COPY package*.json → RUN npm install → COPY . . → CMD) — because package*.json is unchanged, everything from FROM through RUN npm install hits cache, and only COPY . . onward re-runs

InstructionNaive orderOptimized order
FROM node:20-slimCache hit (base image unchanged)Cache hit
COPY package*.json ./(this instruction doesn’t exist)Cache hit (package.json unchanged)
RUN npm installCache miss (re-run because COPY . . was invalidated upstream)Cache hit (input package*.json unchanged)
COPY . .Cache miss (app.js checksum changed)Cache miss (same reason)
CMD [...]Cache miss (preceding layer was invalidated)Cache miss (same reason — though CMD is a metadata instruction, so materializing that layer is essentially instantaneous)

With the optimized order, the build skips the most time-consuming step — installing dependencies — while the naive order re-runs npm install on every single build. For projects with many dependencies, or in CI pipelines, this can amount to a difference of tens of seconds to several minutes per build; the actual time saved depends on network speed, package count, and registry-side caching, so no single generalized figure is claimed here.

On the validity of the Dockerfile syntax

Since there’s no Docker runtime available, the two Dockerfiles above were not actually built for verification. They were, however, checked by eye against the Dockerfile reference — the argument shape of each instruction and the ordering constraints between instructions — and no syntax errors were found. In an environment where BuildKit is available, this could be verified mechanically with docker build --check, discussed below.

Shrinking images with multi-stage builds

Packing everything — including toolchains that are only needed at build time, like RUN apt-get install -y build-essential — into a single image leaves compilers and header files that aren’t needed at runtime sitting in the final image, bloating its size. Multi-stage builds solve this by allowing multiple FROM instructions in one Dockerfile. Each FROM starts a new, independent build stage, and COPY --from=<stage name> lets you carry only the artifacts you need from one stage into the next.

Single-stage version

FROM golang:1.25
WORKDIR /src
COPY . .
RUN go build -o /bin/hello .
CMD ["/bin/hello"]

This image retains the entire Go toolchain (the compiler, standard library sources, the module cache, and so on) — none of which is needed at runtime.

Multi-stage version

# Build stage: includes the full toolchain
FROM golang:1.25 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/hello .

# Runtime stage: only the artifact needed to run
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

AS build names the build stage, and COPY --from=build copies only the single static binary out of it. The final image contains nothing but scratch (a completely empty base image) and the binary — the Go toolchain and source code stay behind in the build stage and never make it into the final image. This structure follows the pattern shown in the official Docker documentation on multi-stage builds .

A sense of the image-size difference (not measured in this article — figures quoted from published benchmarks)

Because Docker isn’t available, these two images were not actually built and measured for this article. The figures below are not measurements taken for this article; they are explicitly quoted from third-party published benchmark write-ups.

These numbers vary substantially depending on the base image tag and the application’s dependencies, so if you want to confirm the effect on your own project, the recommended approach is to compare the single-stage and multi-stage image sizes directly with docker images.

docker build -t hello-single -f Dockerfile.single .
docker build -t hello-multi -f Dockerfile.multi .
docker images | grep hello

The same idea applies to interpreted languages like Node.js or Python. Use a dedicated build stage with tsc and devDependencies for TypeScript compilation, and copy only the build output and production dependencies into a minimal runtime-stage base image.

FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
CMD ["node", "dist/server.js"]

Building with node:20 (the full variant, which includes the native-module compilation tooling needed to build devDependencies) and running with node:20-slim (a minimal Debian-based variant) that receives only the build output means the final image never contains the TypeScript compiler or devDependencies.

Security: the principle of never running as root

As mentioned in the USER section, processes inside a Docker container run as root (UID 0) by default unless you specify a USER instruction. This isn’t just a matter of style — it directly affects the container’s attack surface.

Why this matters

In the standard configuration — one that doesn’t use user-namespace remapping (--userns-remap and similar) — root inside a container shares the exact same UID 0 as root on the host OS. Under that assumption, running as root carries several risks:

  1. A wider blast radius on container escape: if an attacker manages to “escape” from the container to the host by exploiting a kernel vulnerability or a bug in the container runtime itself, holding root inside the container makes it far more likely they can also perform root-equivalent operations on the host. Running as a non-root user means the same vulnerability, if exploited, is more likely to be contained within that user’s permissions.
  2. A wider blast radius on application vulnerabilities: if the application itself has an arbitrary-code-execution vulnerability (for example, a supply-chain attack via a dependency), running as root hands over control of the entire container filesystem and every process in it. Running as non-root limits the damage to whatever that user can read and write.
  3. Privilege escalation via volume mounts: if a host directory is bind-mounted into the container, files written by root inside the container remain root-owned on the host side too. This creates a risk of unintentionally overwriting host-side configuration files and the like.

Before / after

# before: no USER instruction → stays root
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]
# after: create a non-root user and switch to it
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
# Create a system user and hand over ownership of the app's files
RUN groupadd --gid 1001 appgroup \
  && useradd --uid 1001 --gid appgroup --shell /bin/false --create-home appuser \
  && chown -R appuser:appgroup /app
USER appuser
CMD ["node", "server.js"]

The official node image actually ships with a general-purpose non-root user named node already set up, so in many cases a single USER node line is all you need. Whenever a base image provides a ready-made unprivileged user, reaching for it is the path of least resistance. If you create your own user, pinning the UID/GID to a fixed value (e.g., 1001) makes the ownership mapping against host-side bind mounts predictable.

One caveat once you add a USER instruction: trying to listen directly on a privileged port below 1024 (e.g., port 80) declared with EXPOSE will fail with a permission error under a non-root user. The usual fix is to listen on an unprivileged port like 8080 inside the container and resolve the mapping from outside with docker run -p 80:8080.

What changed in 2024–2025: docker build –check

BuildKit continues to be actively developed, and there have been several practically relevant changes since 2024. One worth calling out here, since it concerns syntax validation, is docker build --check ( announced on the official Docker blog in July 2024, shipping with Docker Desktop 4.33).

docker build --check .

Adding --check validates the Dockerfile against the published list of build-check rules — consistent casing of stage names, use of deprecated instructions, the recommendation to use the exec form for ENTRYPOINT/CMD, references to files that should be excluded by .dockerignore, and more (21 documented rules at the time of writing) — in under a second, without actually building the image. By default, warnings don’t fail the build, but adding a # check=error=true directive at the top of the Dockerfile makes the build exit with a non-zero status when a check fails, which is useful for catching regressions in CI.

Ideally, the Dockerfile examples in this article would have been validated mechanically with this feature. However, since Docker isn’t available in the environment this article was written in, docker build --check itself could not be run. To be explicit: syntax validity here is limited to the manual, eyeball comparison against the official reference described above.

Separately, late 2024 also brought the docker buildx history command — a subcommand for inspecting and replaying past builds in detail — improving build observability in CI/CD pipelines.

References