# 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.
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.
small imagealpineslim imagereduce sizeminimal base image
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.
non-rootsecurityrun as userdrop rootcontainer security
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.
dockerignoreignore filesexclude from buildbuild context size
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.
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.
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.
✗ 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.
# 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.
combine runreduce layerssmaller imagelayer optimizationcleanup in same layer
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.