Make images build fast and ship small — order your layers so dependency installs are cached, use a multi-stage build to drop build tools, and run as a non-root user.
Docker caches each Dockerfile step as a "layer" and reuses it until something changes — then it rebuilds that step and every step after it. So copy the files that change rarely (package.json) and install BEFORE copying your source code. Now editing your code does not bust the dependency-install cache, and rebuilds take seconds instead of minutes.
FROM node:22-alpine
WORKDIR /app
# 1. Dependencies change rarely — copy + install first (cached)
COPY package*.json ./
RUN npm ci
# 2. Source changes often — copy it AFTER, so edits don't
# invalidate the cached npm install above
COPY . .
CMD ["node", "server.js"]Build tools and dev dependencies do not belong in your final image. A multi-stage build uses one stage to compile/build, then copies only the finished output into a clean, small runtime stage. The build tools stay behind — the shipped image is a fraction of the size.
# --- build stage: has all the dev tooling ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # produces ./dist
# --- runtime stage: clean and small ---
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # production deps only
COPY --from=build /app/dist ./dist # copy just the output
CMD ["node", "dist/server.js"]By default a container runs as root, so a break-in starts with full privileges. The official Node images ship a ready-made non-root "node" user — switch to it with USER before the app runs. Combined with a small base image (alpine here), this is the cheapest security win you get.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node # drop root before running the app
CMD ["node", "server.js"]