Here’s the practical best-practice checklist for building Docker images today:
Use a small, trusted base image and pin versions instead of relying on latest. Docker recommends choosing the right base image, keeping it small, and pinning base image versions for better security and repeatability. (Docker Documentation)
Use multi-stage builds so build tools never end up in the final runtime image. This is one of Docker’s main recommendations for producing smaller, cleaner, more secure images. (Docker Documentation)
Keep the build context small with a .dockerignore file. Excluding node_modules, .git, test artifacts, local env files, and temp files speeds builds and reduces accidental leakage into the image. Docker explicitly recommends using .dockerignore. (Docker Documentation)
Design your Dockerfile to maximize cache reuse. Copy dependency files first, install dependencies, then copy the rest of the app. Since Docker images are layer-based, ordering instructions well can make rebuilds much faster. (Docker Documentation)
Do not install unnecessary packages. Keep the image focused on one service, and remove build-only tools from the final stage. Docker also recommends creating ephemeral containers and decoupling applications where possible. (Docker Documentation)
Run the app as a non-root user whenever possible. Docker’s learning materials call out that a production-ready Dockerfile should improve security by running as non-root. (Docker Documentation)
Rebuild images regularly and use fresh base layers, especially for security patches. Docker recommends rebuilding often and using flags like --pull and, when needed, --no-cache for clean rebuilds. Also build and test images in CI. (Docker Documentation)
A solid production pattern looks like this:
# syntax=docker/dockerfile:1FROM node:22-alpine AS depsWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devFROM node:22-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildFROM node:22-alpineWORKDIR /appENV NODE_ENV=productionCOPY --from=deps /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY package*.json ./USER nodeEXPOSE 3000CMD ["node", "dist/server.js"]
A matching .dockerignore should usually include:
node_modulesnpm-debug.log.git.gitignoreDockerfile*docker-compose*.envcoveragedisttmp
For most teams, the simplest rule set is:
- Small pinned base image
- Multi-stage build
.dockerignore- Cache-friendly Dockerfile order
- Non-root runtime
- Rebuild in CI and scan often (Docker Documentation)
Absolutely — here’s a production-ready Docker image pattern you can reuse for most apps.
Good Dockerfile pattern
# syntax=docker/dockerfile:1# 1) Install dependencies in a separate stageFROM node:22-alpine AS depsWORKDIR /appCOPY package*.json ./RUN npm ci# 2) Build the appFROM node:22-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build# 3) Runtime imageFROM node:22-alpine AS runtimeWORKDIR /appENV NODE_ENV=production# Create/use non-root runtimeUSER node# Copy only what is needed at runtimeCOPY --chown=node:node --from=deps /app/node_modules ./node_modulesCOPY --chown=node:node --from=build /app/dist ./distCOPY --chown=node:node package*.json ./EXPOSE 3000CMD ["node", "dist/server.js"]
Matching .dockerignore
node_modulesnpm-debug.log.git.gitignoreDockerfile*docker-compose*.env.env.*coveragedisttmp.vscode.idea
Why this is a strong default
Docker’s current guidance recommends:
- multi-stage builds to keep the final image smaller and cleaner (Docker Documentation)
- using a
.dockerignorefile to keep the build context small and avoid sending unnecessary files to the builder (Docker Documentation) - structuring the Dockerfile for better cache reuse, like copying dependency manifests before app source (Docker Documentation)
- running the app as a non-root user in production images (Docker Documentation)
- avoiding secrets in
ARGorENV; Docker recommends using secret mounts instead because build args and env vars can be exposed in image metadata or the final image (Docker Documentation)
Even better build command
docker build --pull -t myapp:latest .
--pull helps refresh the base image layers so you don’t keep building on stale images, which aligns with Docker’s recommendation to rebuild often and keep base layers fresh. (Docker Documentation)
7 rules to follow every time
- Pin the base image
FROM node:22.14-alpine
- Do not use
latestin production - Copy dependency files first
COPY package*.json ./RUN npm ciCOPY . .
- Only copy runtime artifacts into the final stage
- Run as non-root
- Keep secrets out of the Dockerfile
- Keep one main responsibility per container when possible (Docker Documentation)
Common mistakes
Bad:
COPY . .RUN npm install
Better:
COPY package*.json ./RUN npm ciCOPY . .
Bad:
FROM node:latest
Better:
FROM node:22-alpine
Bad:
ENV API_KEY=secret123
Better: pass secrets at runtime or use Docker build secrets. (Docker Documentation)
If your app does not need Node at runtime
For frontend apps like React/Vite/Angular/Vue, it is often better to build in Node and serve with Nginx in the final stage, which Docker’s current framework guides also demonstrate for modern frontend apps. (Docker Documentation)
Best-practice summary
Use:
- small pinned base image
- multi-stage build
.dockerignore- cache-friendly layer order
- non-root runtime
- no secrets in
ARGorENV - regular rebuilds with fresh base layers (Docker Documentation)