Steven's Knowledge

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.0

The . at the end is the build context — the directory shipped to the builder. Keep it small with .dockerignore.

The Instructions You'll Use

InstructionPurpose
FROMBase image
WORKDIRSet working directory; creates it if missing
COPYCopy files from build context into the image
ADDLike COPY but also fetches URLs / unpacks tarballs — prefer COPY
RUNRun a command at build time, commits the result
ENVSet environment variable (persists at runtime)
ARGBuild-time variable (does not persist at runtime)
EXPOSEDocument a port (informational; doesn't open it)
USERSwitch the user for following instructions and runtime
CMDDefault command (overridable by docker run)
ENTRYPOINTFixed command; arguments come from CMD or docker run
HEALTHCHECKHow 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
*.log

A good .dockerignore makes builds dramatically faster and prevents whole categories of mistakes.

Image Size

StrategyTypical impact
Multi-stage build50-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 cachesapt-get install ... && apt-get clean && rm -rf /var/lib/apt/lists/*
Pin and minimize installed packagesOnly 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 explorer

CMD vs. ENTRYPOINT

Both define what runs when the container starts. Different flavours:

FormBehavior
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 1

docker 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 runtime
docker 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.

On this page