DK

Best Practices

Docker · 10 entries

Optimize Layer Caching

syntax
# Copy dependency files first, install, then copy source
example
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# NOT:
# COPY . .
# RUN npm ci

Note Docker caches each layer. If you COPY everything first, changing a single source file invalidates the npm ci cache. By copying package files separately, the install layer is cached until dependencies actually change — saving minutes on every build.

Use Small Base Images

syntax
FROM <image>:<version>-alpine
FROM <image>:<version>-slim
example
# Instead of:
FROM node:20          # ~1GB
# Use:
FROM node:20-alpine   # ~130MB
FROM python:3.12-slim # ~150MB vs 900MB for full

Note Alpine images use musl libc instead of glibc, which can cause issues with some native Node.js modules or Python C extensions. Test thoroughly. Debian slim is a safer middle ground if Alpine causes problems.

Run as Non-Root User

syntax
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
example
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["node", "server.js"]

Note Node images ship with a built-in 'node' user. Many images include a non-root user — check the docs before creating your own. Running as root inside a container means a container escape could grant host-level root access.

Always Use .dockerignore

syntax
# .dockerignore
example
node_modules
.git
*.md
.env*
coverage
dist
.DS_Store
__pycache__
*.pyc
.venv

Note Without .dockerignore, a COPY . . sends everything to the daemon including .git (often 100MB+), node_modules, environment files with secrets, and test coverage reports. Builds become slow and images become bloated.

Add Health Checks

syntax
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD <check>
example
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

Note Use wget instead of curl in Alpine images (wget is built in, curl is not). The --start-period gives slow-starting apps time to initialize before health checks count against them.

Pin All Versions

syntax
FROM <image>:<specific_version>
RUN apt-get install -y <package>=<version>
example
FROM node:20.11.1-alpine3.19
RUN apk add --no-cache dumb-init=1.2.5-r3

Note Unpinned versions mean your builds are not reproducible. node:20-alpine could be 20.10 today and 20.12 tomorrow. Pin the full version for critical images. Even apk/apt packages should be pinned in production Dockerfiles.

Use Multi-Stage Builds

syntax
FROM <image> AS build
...
FROM <image>
COPY --from=build ...
example
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
CMD ["/app/server"]

Note The Go SDK (800MB+) stays in the build stage only. The final image is just the static binary on distroless (~2MB). This pattern works for any compiled language and even for frontend builds where you need Node to compile but only serve static files.

Scan Images for Vulnerabilities

syntax
docker scout cves <image>
docker scout quickview <image>
example
docker scout quickview myapp:1.0
docker scout cves --only-fixed myapp:1.0
output
✗ HIGH    2 vulnerabilities
✗ MEDIUM  5 vulnerabilities
✓ LOW     3 vulnerabilities (fixes available)

Note Run scans in CI before pushing images. The --only-fixed flag filters to vulnerabilities that have a patched version available, so you can focus on actionable fixes rather than the full list.

Combine RUN Commands

syntax
RUN command1 && command2 && command3
example
# Good — single layer, cache cleaned:
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Bad — three layers, cache stuck in layer 1:
# RUN apt-get update
# RUN apt-get install -y curl
# RUN rm -rf /var/lib/apt/lists/*

Note Separate RUN statements each create a layer. The rm in a later layer does not reduce the image size because the deleted files still exist in a previous layer. Always install and clean up in the same RUN command.

Handle Signals with a Proper Init

syntax
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
example
FROM node:20-alpine
RUN apk add --no-cache dumb-init
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Note Node.js and Python do not handle signals well as PID 1. Without an init process, SIGTERM from docker stop is not forwarded to your app, and the container takes the full 10-second timeout before being killed. dumb-init or tini fixes this.