Dockerfile
Build your own images - the Dockerfile syntax, layers and caching, multi-stage builds, and image sizing
Dockerfile
A Dockerfile is the recipe for an image. The Docker daemon (or a builder like BuildKit) reads it top-to-bottom and produces a layered filesystem.
A Minimal Example
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Build and run:
docker build -t myapp:0.1.0 .
docker run --rm -p 3000:3000 myapp:0.1.0The . at the end is the build context — the directory shipped to the builder. Keep it small with .dockerignore.
The Instructions You'll Use
| Instruction | Purpose |
|---|---|
FROM | Base image |
WORKDIR | Set working directory; creates it if missing |
COPY | Copy files from build context into the image |
ADD | Like COPY but also fetches URLs / unpacks tarballs — prefer COPY |
RUN | Run a command at build time, commits the result |
ENV | Set environment variable (persists at runtime) |
ARG | Build-time variable (does not persist at runtime) |
EXPOSE | Document a port (informational; doesn't open it) |
USER | Switch the user for following instructions and runtime |
CMD | Default command (overridable by docker run) |
ENTRYPOINT | Fixed command; arguments come from CMD or docker run |
HEALTHCHECK | How Docker should test container health |
Layers and Caching
Each instruction creates a layer. Builds cache layers and invalidate the cache from the first change downward. Order instructions from least-likely to most-likely to change:
# Bad — code changes invalidate the install
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
# Good — dependencies cached separately from code
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .In the bad version, every code edit re-runs npm ci. In the good version, only changes to package.json / package-lock.json do.
Cache Mounts (BuildKit)
For tools with long-lived caches (npm, pip, Go modules, apt), persist them across builds:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY . .The --mount=type=cache mount survives between builds but isn't part of the image — fast and small.
Multi-Stage Builds
The pattern that single-handedly cuts most image sizes 50-90%: build in one stage, ship from a clean minimal stage.
Node.js (TypeScript / Next.js)
# syntax=docker/dockerfile:1.7
# Stage 1 — install dependencies (cached)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# Stage 2 — build the app
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
# Stage 3 — runtime (small, no build tools)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 appgroup \
&& adduser --system --uid 1001 appuser
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]The runtime stage doesn't carry the source code, dev dependencies, or build tools.
Go (Statically Linked)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" -o /app/server ./cmd/server
# Distroless / scratch — almost nothing else in the image
FROM gcr.io/distroless/static-debian12 AS runner
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]A statically-linked Go binary in scratch or distroless/static lands you a ~10-20 MB image with one executable and the CA bundle.
Python
FROM python:3.12-slim AS builder
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt ./
RUN pip install --user -r requirements.txt
COPY . .
FROM python:3.12-slim AS runner
WORKDIR /app
RUN useradd --create-home --uid 1001 appuser
USER appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH=/home/appuser/.local/bin:$PATH
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"].dockerignore
The build context is everything in . sent to the builder. Without .dockerignore, that includes node_modules, .git, dist, and any large local files — slow to send and a privacy / security risk if anything secret slips into a layer.
# .dockerignore
node_modules
.git
.env
.env.*
*.md
docker-compose*.yml
.github
coverage
.next
dist
build
.vscode
.idea
*.logA good .dockerignore makes builds dramatically faster and prevents whole categories of mistakes.
Image Size
| Strategy | Typical impact |
|---|---|
| Multi-stage build | 50-90% smaller |
| Alpine base image | ~100 MB → ~5 MB base |
| Distroless base image | ~5 MB → ~2 MB; no shell, smaller attack surface |
scratch base (Go, Rust binaries) | Just your binary |
Combine RUN steps; clean caches | apt-get install ... && apt-get clean && rm -rf /var/lib/apt/lists/* |
| Pin and minimize installed packages | Only what's needed at runtime |
Inspect what's eating space:
docker images | head
docker image history myapp:0.1.0
docker run --rm -it wagoodman/dive myapp:0.1.0 # interactive layer explorerCMD vs. ENTRYPOINT
Both define what runs when the container starts. Different flavours:
| Form | Behavior |
|---|---|
CMD ["node", "server.js"] | Default command; overridable by docker run image otherthing |
ENTRYPOINT ["node", "server.js"] | Fixed; docker run image arg appends arg |
ENTRYPOINT ["my-entrypoint.sh"] + CMD ["--help"] | Entrypoint script with default args |
Use shell form (CMD npm start) sparingly — it runs through /bin/sh -c, which means no PID 1 signal forwarding (your SIGTERM won't reach the app).
HEALTHCHECK
Containers can self-report health. Both Docker and orchestrators read it:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --spider -q http://localhost:3000/health || exit 1docker ps then shows healthy / unhealthy and Compose can wait on it with depends_on: { condition: service_healthy }.
Build Args vs. Env
ARG NODE_ENV=production # build-time only
ENV NODE_ENV=$NODE_ENV # persist to runtimedocker build --build-arg NODE_ENV=staging -t myapp:staging .Build args are baked into the image's layers. Don't put secrets there — they're recoverable from the image. For real secrets at build time, use BuildKit's --mount=type=secret.
What's Next
You can build small, secure, fast-rebuilding images. Next: wire several of them together — apps + databases + caches — with Docker Compose.