Skip to content
Blog

Anatomy of a Dockerfile: Instructions from Top to Bottom

FROM, WORKDIR, COPY, RUN, CMD, ENTRYPOINT, USER, HEALTHCHECK — what each instruction does, the order they run in, and how they shape your image layers.

Published on • October 15, 2026

AI Assistant

A Dockerfile is an ordered list of instructions that the builder executes top to bottom to produce an image. Some instructions add filesystem layers, some only touch metadata, and some define how the container behaves at runtime. Knowing which is which — and in what order they take effect — is most of what separates a 200 MB image from a 2 GB one.

Format and parser directives

A Dockerfile is plain text, one instruction per line, with # comments. The file usually opens with a parser directive, which configures the builder itself rather than the image:

# syntax=docker/dockerfile:1

This pins the Dockerfile frontend — pulling the latest docker/dockerfile:1 frontend gives you features like here-documents, RUN --mount, and COPY --link regardless of the local daemon version. There’s also # escape to change the line-continuation character (useful on Windows) and # check to set skip rules for build checks.

Two forms apply to several instructions: exec form (["executable", "arg"], no shell involved) and shell form (a string run via /bin/sh -c). The exec form is required for correct signal handling and argument parsing in ENTRYPOINT/CMD.

Build-time vs runtime: the big divide

Instructions split into two roles:

  • Build-time instructions execute while building and mostly create layers: FROM, RUN, COPY, ADD, plus context-setters like WORKDIR, ENV, ARG, USER.
  • Runtime instructions record how the container should behave: CMD, ENTRYPOINT, EXPOSE, VOLUME, STOPSIGNAL, HEALTHCHECK, LABEL. These write image metadata, not filesystem layers — which is why docker image history shows 0B for them.

The instructions, top to bottom

FROM

Sets the base image — it’s the first layer of your stack. Everything before the first FROM is the global scope, where only ARG is allowed. An ARG declared before FROM can parameterize the base itself:

ARG GO_VERSION=1.24
FROM golang:${GO_VERSION}-alpine AS build

ARG values declared before FROM don’t carry into the build stages after it — declare them again inside the stage if you need them.

WORKDIR

Sets the working directory for subsequent instructions and for the runtime. It creates the directory if missing. Never write RUN mkdir -p /app && cd /app — a stray cd doesn’t persist across RUN instructions anyway, and the mkdir burns a layer. WORKDIR /app does both in metadata.

COPY and ADD

Both add files from the build context into the image. ADD has two extra behaviors: it can fetch remote URLs, and it automatically extracts local tar archives. Those conveniences are footguns — a fetched URL isn’t cached by digest, and a silently-extracted tar can land files in a place you didn’t expect. The reference’s own guidance: prefer COPY unless you specifically need ADD’s unpacking. Both support --chown, --chmod, and --link (which lets the layer be created independently of the parent layer, improving cache reuse).

RUN

Executes a command in a new layer and commits the result. This is where layer count and image size are won or lost. Two key details:

  • Cache invalidation: the builder caches a RUN step keyed on the instruction and the state of files it can see. Change the instruction or the files copied by an earlier COPY, and every layer from that point is rebuilt.
  • Mounts: RUN --mount=type=cache gives a persistent cache directory across builds without baking it into the layer; --mount=type=secret injects a secret at build time without it ever landing in a layer; --mount=type=bind, tmpfs, and ssh round out the set.

Multi-line runs should use && continuation or here-docs, and should clean up in the same instruction — apt-get update && apt-get install -y --no-install-recommends pkg && rm -rf /var/lib/apt/lists/* — because cleanup in a separate RUN puts the files in one layer and the deletion in another, leaving the bytes in the image.

ENV vs ARG

ENV sets environment variables that persist at runtime and affect build steps — which means changing one invalidates the cache from that point. ARG is build-only, and its values don’t reach the running container. Don’t use ENV to hold secrets: environment variables live in the image config, visible to anyone who inspects the image or runs the container.

USER

Sets the user (and optionally group) for subsequent RUN, CMD, and ENTRYPOINT. The default is root. Set it as late as possible — you often need root for package installs — but set it before the runtime instructions so the process doesn’t run as root. Files copied earlier are root-owned; use COPY --chown=app:app to hand ownership over correctly.

CMD and ENTRYPOINT

These define the container’s default command, and they interact in a specific way:

  • ENTRYPOINT (exec form) is the fixed executable. Arguments passed to docker run image ... are appended to it.
  • CMD provides default arguments — and is fully overridden by any arguments on docker run.

So ENTRYPOINT ["python"] + CMD ["app.py"] runs python app.py, and docker run myimage -O2 runs python -O2. If you want users to be able to swap the whole command, use only CMD. Always prefer the exec form: the shell form runs your command as a child of /bin/sh, which doesn’t forward signals properly, so your container’s SIGTERM handling breaks and every shutdown becomes a 10-second SIGKILL countdown.

EXPOSE

Documents which ports the container listens on. It’s metadata — it does not publish the port to the host. docker run -p (or Compose ports:) does the publishing; EXPOSE tells humans and tools what to publish.

HEALTHCHECK

Defines a command the daemon runs periodically to decide if the container is healthy:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["wget", "-qO-", "http://localhost:8080/healthz"]

This is what condition: service_healthy in Compose waits on, and what orchestrators use for rollout decisions. Use the exec form here for the same signal-handling reasons.

A complete, sensible shape

# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
USER node
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://localhost:8080/healthz || exit 1
CMD ["node", "dist/server.js"]

Every choice here is layer-shaped: dependency manifests copied before source so the expensive npm ci layer stays cached when only code changes; build tooling confined to the build stage and excluded from the final image entirely; the runtime user set after the files are owned correctly.

Practical gotchas

  • Order defines your cache. Put instructions that change rarely first (base image, dependency manifests, dependency install) and instructions that change constantly last (source copy). One character change in a early-copied file rebuilds everything after it.
  • RUN rm doesn’t shrink the image. Deleted files remain in earlier layers; only multi-stage builds or better ordering actually remove bytes.
  • CMD gets clobbered by docker run arguments; ENTRYPOINT doesn’t. Know which one you want user arguments to replace.
  • Shell-form ENTRYPOINT/CMD breaks signal handling. Use exec form so your process is PID 1 and receives SIGTERM.
  • No .dockerignore means context chaos. The entire build context gets sent to the daemon — including .git, node_modules, and secrets — slowing builds and busting caches. Write one.
  • Build args leak into layers. An ARG used in a RUN is recorded in the image history. Never pass secrets as build args; use --mount=type=secret.
  • Here-document quoting controls expansion timing. In COPY <<EOF, an unquoted delimiter expands ARG variables at build time; a quoted delimiter (<<"EOF") defers expansion to runtime — a subtle difference that decides whether a variable bakes into the file.
  • MAINTAINER is deprecated. Use LABEL org.opencontainers.image.authors="..." instead.

Sources