Dockerfile Structure #
A Dockerfile looks like a sequence of simple commands, and that’s exactly what it is. But behind that simplicity lies a structure that determines whether the resulting image is small and secure, or large and fragile. Every instruction has a specific role, and the order they’re written in isn’t accidental — it’s a build graph that determines the image’s performance, size, and maintainability.
Understanding Dockerfile structure isn’t just about being able to write images. It’s about understanding how each instruction interacts with the layer system, how caching works, and how small Dockerfile decisions can have big production impacts. Backend engineers, DevOps, and platform engineers who understand Dockerfile structure can build reproducible, small, and secure images — without expensive trial and error.
This article covers every important instruction commonly used, its specific role, the differences between frequently-confused instructions (CMD vs ENTRYPOINT, COPY vs ADD, ARG vs ENV), and practices for writing clean Dockerfiles.
General Dockerfile Anatomy #
A Dockerfile is essentially a list of sequential instructions executed by the Docker daemon during docker build. Each instruction produces one layer in the image. Layers are read-only, and a new layer on top can replace files from layers below (but doesn’t delete those layers themselves — all layers remain until the image is pruned).
flowchart TD
A[FROM: Base Image] --> B[ARG: Build-time Variable]
B --> C[ENV: Runtime Variable]
C --> D[WORKDIR: Working Directory]
D --> E[COPY/ADD: Copy Files]
E --> F[RUN: Execute Commands]
F --> G[EXPOSE: Port Documentation]
G --> H[USER: Runtime User]
H --> I[ENTRYPOINT/CMD: Startup]The order above is the common logical order. Not every instruction is mandatory, and some can be repeated (especially FROM in multi-stage builds). FROM is mandatory (except for an empty Dockerfile, which makes no sense), as is at least one CMD or ENTRYPOINT so the container knows what to run.
One thing to understand from the start: every Dockerfile line is one layer. Write 20 RUN lines and you get 20 layers. Combine 20 commands into one RUN with && and you get 1 layer. More layers = a bigger image and longer pull times. But merging layers too aggressively = less granular caching. It’s a trade-off that needs understanding.
FROM — Base Image #
Function #
FROM sets the base image that becomes the foundation of the image you’re building. It must be the first non-comment instruction in the Dockerfile (except for a special ARG used in FROM).
FROM node:20-alpine
node:20-alpine means: take the node image, version 20, with the alpine variant (minimal Linux). Docker pulls this image from the registry, and all subsequent instructions run on top of this image’s filesystem.
Important Rules #
Explicit tags, not latest. Always specify the version tag on the base image. FROM node:latest is an anti-pattern: you never know the exact version you’ll get, and a build that “works” today can differ tomorrow.
// ✗ Anti-pattern: non-deterministic
FROM node:latest
// ✓ Correct: pin version and variant
FROM node:20.11-alpine
Base image variants matter a lot. Official images usually have several variants:
| Variant | Size | Notes |
|---|---|---|
-alpine | Small | musl libc, occasional compatibility issues |
-slim | Medium | Minimal Debian, glibc |
| default | Large | Full OS with many utilities |
-distroless | Very small | No shell, no package manager |
Multi-stage builds use FROM more than once. This is a very common pattern covered in more detail later, but the gist: the second FROM is usually a smaller base image for runtime.
# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o app
# Stage 2: runtime
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/app /app
CMD ["/app"]
It’s important to name the build stage (AS builder) so other stages can reference it with COPY --from=builder.
ARG — Build-time Variable #
Function #
ARG defines a variable only available during the build process — inside FROM, RUN, LABEL, and other build-time instructions. This variable is not available in the runtime container.
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION}-alpine
The GO_VERSION value can be overridden at build time with the --build-arg flag:
docker build --build-arg GO_VERSION=1.21 -t myapp .
Characteristics #
- Scope: only in instructions after the
ARGdeclaration, until the end of the build (or a newFROM). - Overridable: via
--build-argor a default value after=. - Not safe for secrets:
ARGvalues are visible indocker historyafter the image is built.
// ANTI-PATTERN: secret in ARG
ARG DB_PASSWORD=supersecret
RUN echo "password=$DB_PASSWORD" >> /app/config
// CORRECT: secrets mounted at runtime or via a secret manager
ARG DB_PASSWORD # empty default value
ARG arguments are very useful for build parameters that legitimately vary between builds, like runtime versions, target platforms, or build flags. But it is not the place for runtime configuration or credentials.
ENV — Runtime Environment Variable #
Function #
ENV defines environment variables available when the container runs. These variables are also available during the build (in RUN after the ENV declaration), but their main purpose is runtime.
ENV NODE_ENV=production
ENV APP_PORT=8080
The value NODE_ENV=production will be available inside the container, and Node.js applications automatically read this variable for optimizations (e.g. dropping source maps, enabling minification).
ARG vs ENV Differences #
| Aspect | ARG | ENV |
|---|---|---|
| Available at build | Yes | Yes |
| Available at runtime | No | Yes |
| Overridden via | --build-arg | --env or -e |
| Safe for secrets | No | No |
| Stored in the image | No (default) | Yes |
ARG is a build variable, ENV is a runtime variable. They’re often used together, but for different purposes.
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION}-alpine
ENV APP_ENV=production \
APP_PORT=8080
In the example above, GO_VERSION is only relevant at build time (choosing the base image), while APP_ENV and APP_PORT persist in the running container.
A Note on Secrets #
Neither ARG nor ENV should be used to store credentials. Both end up in the image history and can be inspected with docker history or docker inspect. For secrets, use:
- Docker secrets (on Docker Swarm)
- Kubernetes secrets (mounted as volumes or env)
- External secret managers (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager)
- BuildKit secret mounts (
--mount=type=secret) for build-time secrets
WORKDIR — Working Directory #
Function #
WORKDIR sets the working directory for subsequent instructions. It makes relative paths explicit and avoids using cd, which pollutes layers.
WORKDIR /app
COPY . .
After WORKDIR /app, all relative paths are based on /app. COPY . . means: copy the build context contents to /app in the image.
Good Practices #
Always use WORKDIR, not cd in RUN.
// ✗ Anti-pattern: cd creates an extra layer
RUN cd /app && npm install
// ✓ Correct: WORKDIR is cleaner and reusable
WORKDIR /app
RUN npm install
RUN cd /app && npm install works, but cd adds an extra layer and makes instructions harder to read. WORKDIR is more explicit and can be referenced by many instructions.
It can be called repeatedly. If you need to switch directories, call WORKDIR again. No side effects.
WORKDIR /app
COPY package*.json ./
RUN npm install
WORKDIR /app/src
COPY . .
RUN npm run build
WORKDIR automatically creates the directory if it doesn’t exist.
COPY — Copying Files from the Build Context #
Function #
COPY copies files and directories from the build context (where docker build is run) into the image.
COPY package.json package-lock.json ./
COPY . .
The most common forms:
COPY <src> <dest>— copies one or more files/directories.COPY --from=<stage> <src> <dest>— copies from another build stage (multi-stage).
Good Practices #
Copy dependency definitions first, source code last. This is the most important caching rule.
// ✗ Anti-pattern: source code on top, npm install cache always invalidated
COPY . .
RUN npm install
// ✓ Correct: dependencies first, source last
COPY package*.json ./
RUN npm install
COPY . .
Use .dockerignore. This file defines what does not get sent to the Docker daemon. Without .dockerignore, every file in the build context gets sent — including local node_modules, .git, secret files, and more.
# .dockerignore
node_modules
.git
.env
*.log
dist
build
.DS_Store
.dockerignore is as important as .gitignore. It speeds up builds, shrinks the context size, and prevents sensitive data leaks.
ADD — COPY Plus Extra Features #
Additional Functions #
ADD has two extra abilities compared to COPY:
- Automatic tar archive extraction — if
<src>is a local.tarfile, Docker extracts it. - Download from URLs —
ADDcan fetch files from a URL.
ADD archive.tar.gz /app/
ADD https://example.com/file.txt /app/
Recommendation #
Use COPY by default, unless you need ADD’s features.
The reasons:
ADDfrom a URL is not recommended — no checksum validation, and dependence on an external URL makes builds non-deterministic.- Automatic tar extraction is useful, but can be replaced with the more explicit
RUN tar -xzf archive.tar.gz -C /app/. COPYis clearer to read —COPYonly copies, whileADDhas two behaviors.
The general principle: use the instruction that best fits your needs, and avoid features you don’t use.
RUN — Executing Commands at Build #
Function #
RUN executes commands inside the image during the build. The result becomes part of the image. This is the main instruction for installing dependencies, running setup, and modifying the image.
RUN apt-get update && apt-get install -y curl
Syntax Forms #
There are two forms: shell form and exec form.
# Shell form — run with /bin/sh -c
RUN apt-get update && apt-get install -y curl
# Exec form — executed directly, without a shell
RUN ["apt-get", "update", "&&", "apt-get", "install", "-y", "curl"]
Shell form is more common and easier to read. Exec form is useful when you need to avoid a shell, for example to avoid signal handling issues.
Good Practices #
Combine related commands, and remove caches in the same layer.
// ✗ Anti-pattern: cache not cleaned, layers bloat
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
// ✓ Correct: one layer, cache cleaned in the same place
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
This principle applies to all package managers: apt, apk, yum, pip, npm. Caches must be removed in the same RUN as the installation, because subsequent layers can’t shrink previous ones.
Each RUN = one layer. Combining RUN commands = fewer layers = a smaller image. But combining too many = less granular caching. Understand the trade-off.
EXPOSE — Port Documentation #
Function #
EXPOSE documents the ports the application uses inside the container. It’s documentation only — EXPOSE doesn’t open the port to the host.
EXPOSE 8080
To open a port to the host, you still need the -p flag on docker run or the ports configuration in Docker Compose.
docker run -p 8080:8080 myapp
Why It Still Matters #
Even though EXPOSE doesn’t open ports, it has several important functions:
- Documentation — Dockerfile readers immediately know which ports the application uses.
- Docker Compose —
EXPOSEis a hint for automatic port mapping. - Orchestrators — some orchestrators (especially older ones) read
EXPOSEfor internal configuration. - Tooling — some tools (like
docker run -P) randomly map allEXPOSEd ports to host ports.
USER — Runtime User #
Function #
USER sets the user used for subsequent instructions and when the container runs. The default is root (UID 0), which is a security risk.
RUN adduser -D -u 1001 appuser
USER appuser
Why It Matters #
Running containers as root is dangerous. If an attacker escapes the container (container escape), they get root access on the host. The higher the privilege inside the container, the bigger the impact.
The least privilege principle: containers should only have the permissions needed to run their task, nothing more.
Common Patterns #
Create an explicit user with a fixed UID. A fixed UID matters for consistent permissions on volume mounts.
RUN addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
USER appuser
Distroless images already have the nonroot user. If you use gcr.io/distroless/base-debian12, just use USER nonroot:nonroot.
FROM gcr.io/distroless/base-debian12
USER nonroot:nonroot
CMD ["/app"]
VOLUME — Declaring Mount Points #
Function #
VOLUME declares a mount point for persistent data outside the container filesystem. It tells Docker that this directory will hold data that must survive beyond the container’s lifecycle.
VOLUME ["/data", "/logs"]
Characteristics #
- Data inside
VOLUMEisn’t committed when a new image is built from the container. - Data mounts to the host or to a named volume by default.
- A declaration, not initialization —
VOLUMEonly declares; the data must be populated at runtime.
In practice, VOLUME in Dockerfiles is rarely used. It’s more common to declare volumes in docker run -v or docker-compose.yml. But VOLUME is useful as documentation that a directory needs persistent data.
HEALTHCHECK — The Operational Contract #
Function #
HEALTHCHECK defines how Docker checks whether a container is truly healthy and ready to serve requests. This is a very important instruction for orchestrated environments (Kubernetes, ECS, Swarm).
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Parameters:
--interval— time between checks (default 30s).--timeout— per-check timeout (default 30s).--start-period— grace period after container start (default 0s).--retries— consecutive failures before theunhealthystatus (default 3).
Important for Distroless #
If the base image has no curl or wget (e.g. distroless), HEALTHCHECK can still run — but you need to think about how to check. Alternatives:
- An HTTP endpoint checked from inside the application (e.g. the app itself writes health status to a file).
- Use a base image with a shell for the healthcheck (but this increases image size).
- Check from outside with a sidecar container (more complex, but truly separate).
A note on distroless: Distroless images deliberately remove the shell and common utilities likecurl. If you useHEALTHCHECK CMD curl ...on distroless, Docker will error. The solution: use an endpoint checked via TCP, or move the healthcheck logic to a sidecar.
ENTRYPOINT vs CMD #
These two instructions are often confused, even though their roles differ. Both define what runs when the container starts.
CMD #
CMD is the default command, easily overridden at docker run.
CMD ["npm", "start"]
Runtime override:
docker run myapp npm test
# `npm test` replaces `npm start`
ENTRYPOINT #
ENTRYPOINT is the main executable, harder to override. It becomes the program running inside the container.
ENTRYPOINT ["python", "app.py"]
Runtime override needs the --entrypoint flag:
docker run --entrypoint python myapp app.py --version
Combining ENTRYPOINT + CMD #
The most common pattern is combining both: ENTRYPOINT as the main executable, CMD as the default arguments.
ENTRYPOINT ["python"]
CMD ["app.py"]
Run without arguments, the container runs python app.py. Run with arguments, CMD is overridden but ENTRYPOINT stays.
docker run myapp # → python app.py
docker run myapp test.py # → python test.py
Comparison #
| Aspect | CMD | ENTRYPOINT |
|---|---|---|
| Override | Easy | Needs --entrypoint |
| More than one allowed | No (last one wins) | No (last one wins) |
| Best for | Default commands with flexibility | A consistent main binary |
| Form to use | Exec form (["..."]) | Exec form (["..."]) |
Avoid shell form for CMD and ENTRYPOINT. Shell form runs the command via /bin/sh -c, which swallows signals and prevents the container from shutting down properly. Always use exec form:
// ✗ Anti-pattern: shell form, signals don't pass through
CMD npm start
// ✓ Correct: exec form, signals reach the main process
CMD ["npm", "start"]
Dockerfile Writing Practices #
Now that you know every instruction, let’s look at a good order for writing a Dockerfile.
flowchart TD
A[1. ARG for build parameters] --> B[2. FROM base image]
B --> C[3. LABEL for metadata]
C --> D[4. ENV for runtime]
D --> E[5. WORKDIR]
E --> F[6. Install OS dependencies]
F --> G[7. Copy dependency definitions]
G --> H[8. Install app dependencies]
H --> I[9. Copy source code]
I --> J[10. Multi-stage copy artifacts]
J --> K[11. EXPOSE]
K --> L[12. USER non-root]
L --> M[13. ENTRYPOINT/CMD]The principle behind this order:
- Rarely-changing things on top — base image, OS dependencies.
- Frequently-changing things at the bottom — application source code.
- Runtime configuration in the middle — env variables, workdir.
- Startup definitions at the end — entrypoint, cmd, user.
This order maximizes cache hits and minimizes build time.
A Complete Dockerfile Example #
To see all the instructions above in context, consider this multi-stage Dockerfile for a Go application:
# ==== Build stage ====
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION}-alpine AS builder
LABEL stage=builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" -o app
# ==== Runtime stage ====
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
Note:
ARGoutside the firstFROMis used for the Go version parameter.LABELadds metadata.- The build stage has a compiler and git; the runtime stage doesn’t.
--from=buildercopies the artifact from the previous stage.- The runtime image (
distroless) has no shell, no package manager. USER nonroot:nonrootensures the container doesn’t run as root.ENTRYPOINTis in exec form, with an absolute path.
Summary #
- Dockerfile structure is the sequence of instructions executed top-to-bottom. Each instruction produces one layer.
FROMis mandatory and sets the base image. Always pin the version, avoidlatest. Multi-stage builds useFROMmore than once.ARGis for build parameters,ENVfor runtime variables. Neither is safe for secrets.WORKDIRis better thancdinRUN.COPYis for copying files,ADDonly if you need tar extraction.RUNcombines related commands and removes caches in the same layer. EachRUN= one layer.EXPOSEis only port documentation.USERmust be non-root in production.HEALTHCHECKis for orchestrators.ENTRYPOINTis the main executable,CMDis the default command. Use exec form for both so signal handling works correctly.- A good order = rarely-changing things on top, frequently-changing at the bottom. This maximizes cache hits.