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"]FROMpicks the base image; every later instruction builds on top of it.WORKDIRsets the working directory for subsequent instructions and at runtime, so you never needcd.CMDis the default command and is replaced by anything appended todocker run.ENTRYPOINTis the fixed program andCMDthen supplies its default arguments.USERdrops 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| Instruction | Layer behaviour |
|---|---|
RUN | Executes at build time; each one becomes a layer |
COPY / ADD | Layer is invalidated when the copied content changes |
ENV / WORKDIR / EXPOSE | Metadata only; tiny layers |
CMD / ENTRYPOINT | Runtime 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=buildcan pull from an earlier stage or from any image;--from=0also 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.Related
Images and containers Compose, volumes and networking
Last refreshed 2026-09-18.