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 likeWORKDIR,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 whydocker image historyshows0Bfor 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
RUNstep keyed on the instruction and the state of files it can see. Change the instruction or the files copied by an earlierCOPY, and every layer from that point is rebuilt. - Mounts:
RUN --mount=type=cachegives a persistent cache directory across builds without baking it into the layer;--mount=type=secretinjects a secret at build time without it ever landing in a layer;--mount=type=bind,tmpfs, andsshround 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 todocker run image ...are appended to it.CMDprovides default arguments — and is fully overridden by any arguments ondocker 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 rmdoesn’t shrink the image. Deleted files remain in earlier layers; only multi-stage builds or better ordering actually remove bytes.CMDgets clobbered bydocker runarguments;ENTRYPOINTdoesn’t. Know which one you want user arguments to replace.- Shell-form
ENTRYPOINT/CMDbreaks signal handling. Use exec form so your process is PID 1 and receives SIGTERM. - No
.dockerignoremeans 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
ARGused in aRUNis 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 expandsARGvariables at build time; a quoted delimiter (<<"EOF") defers expansion to runtime — a subtle difference that decides whether a variable bakes into the file. MAINTAINERis deprecated. UseLABEL org.opencontainers.image.authors="..."instead.