Docker Image Optimization: Best Practices & Tips

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:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

A matching .dockerignore should usually include:

node_modules
npm-debug.log
.git
.gitignore
Dockerfile*
docker-compose*
.env
coverage
dist
tmp

For most teams, the simplest rule set is:

  1. Small pinned base image
  2. Multi-stage build
  3. .dockerignore
  4. Cache-friendly Dockerfile order
  5. Non-root runtime
  6. 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 stage
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 2) Build the app
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 3) Runtime image
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Create/use non-root runtime
USER node
# Copy only what is needed at runtime
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

Matching .dockerignore

node_modules
npm-debug.log
.git
.gitignore
Dockerfile*
docker-compose*
.env
.env.*
coverage
dist
tmp
.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 .dockerignore file 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 ARG or ENV; 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

  1. Pin the base image
FROM node:22.14-alpine
  1. Do not use latest in production
  2. Copy dependency files first
COPY package*.json ./
RUN npm ci
COPY . .
  1. Only copy runtime artifacts into the final stage
  2. Run as non-root
  3. Keep secrets out of the Dockerfile
  4. Keep one main responsibility per container when possible (Docker Documentation)

Common mistakes

Bad:

COPY . .
RUN npm install

Better:

COPY package*.json ./
RUN npm ci
COPY . .

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 ARG or ENV
  • regular rebuilds with fresh base layers (Docker Documentation)

Leave a comment