Writing a Dockerfile

Instructions that matter, why layer order decides your build times, and how multi-stage builds keep production images small.

A first Dockerfile

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
  • FROM picks the base image; every later instruction builds on top of it.
  • WORKDIR sets the working directory for subsequent instructions and at runtime, so you never need cd.
  • CMD is the default command and is replaced by anything appended to docker run. ENTRYPOINT is the fixed program and CMD then supplies its default arguments.
  • USER drops root. Running as a non-root user is a baseline container-hardening step.

Layers and the build cache

Every instruction creates a layer. Docker reuses a cached layer while the instruction and its inputs are unchanged, but the moment one layer changes every later layer is rebuilt. Order instructions from least to most volatile.

# slow: any source change reinstalls every dependency
COPY . .
RUN npm ci

# fast: dependencies stay cached until the manifest changes
COPY package*.json ./
RUN npm ci
COPY . .
docker build -t myapp:1.0 .
docker build --no-cache -t myapp:1.0 .        # verify without stale layers
docker build --target build -t myapp:debug .  # stop at a named stage

cat > .dockerignore <<'EOF'
node_modules
.git
dist
*.log
EOF
InstructionLayer behaviour
RUNExecutes at build time; each one becomes a layer
COPY / ADDLayer is invalidated when the copied content changes
ENV / WORKDIR / EXPOSEMetadata only; tiny layers
CMD / ENTRYPOINTRuntime only; no build work

Multi-stage builds

# stage 1: build with the full toolchain
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

# stage 2: ship only the binary
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
  • Only the final stage is kept, so compilers, package managers and source code never reach production.
  • Smaller images mean faster pulls, less attack surface and fewer packages to patch.
  • COPY --from=build can pull from an earlier stage or from any image; --from=0 also works by stage index.
⚠️
Never pass secrets with ARG or ENV: they are baked into image layers and readable with docker history. Use BuildKit secret mounts (RUN --mount=type=secret) and keep credentials out of the image entirely.

FAQ

CMD or ENTRYPOINT?
Use ENTRYPOINT for the program that must always run and CMD for its default arguments. With ENTRYPOINT ["node", "server.js"] a user can add flags but cannot accidentally replace the program.
Why is my image hundreds of megabytes?
Usually a full base image plus build tooling. Switch to an -alpine or distroless base, use a multi-stage build so compilers are dropped, and check docker history for the layer that ballooned.

Images and containers Compose, volumes and networking

Last refreshed 2026-09-18.