Optimizing Docker images for production
Optimizing Docker images for production
This article explains how to optimize Docker images for production environments according to the official Docker best practices, for developers and DevOps engineers working with Docker.
Prerequisites
- Docker Engine 24.0 or higher - Basic knowledge of Dockerfiles and container images - Access to a container registry (for example Docker Hub or a private registry) - An application you want to run in a container
Steps
1. Use an official base image
Start with a current official image as the basis for every image. Docker maintains these images with regular security updates and clear documentation. Official images reduce the risk of vulnerabilities in the underlying software stack.
Choose a specific version instead of `latest`:
FROM node:20-alpine
2. Choose the smallest possible base image
Use the smallest possible base image for the runtime stage. Smaller images deploy faster and contain fewer vulnerable components. Alpine variants are a good starting point for many workloads.
Compare:
Large: node:20 (about 1 GB)
Small: node:20-alpine (about 180 MB)
FROM node:20-alpine3. Build with multi-stage builds
Use multi-stage builds to separate the build environment from the runtime environment. This results in a smaller and more secure production image.
Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run buildStage 2: runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/index.js"]
4. Order Dockerfile instructions for optimal caching
Place instructions from least frequently changing to most frequently changing. This keeps the cache valid for earlier layers when you only change the source code.
FROM node:20-alpine
WORKDIR /appCopy package files first - these rarely change
COPY package*.json ./
RUN npm ci
Copy source code - this changes often
COPY . .
CMD ["node", "src/index.js"]
5. Avoid `COPY .` and use `.dockerignore`
Only copy the files the application needs. This reduces the image size and improves caching.
Create a `.dockerignore` file in the project root:
node_modules
npm-debug.log
.git
.gitignore
Dockerfile
.dockerignore
.env
coverage
distUse targeted COPY instructions:
COPY package*.json ./
COPY src/ ./src/
COPY public/ ./public/6. Remove unnecessary dependencies and tools
Leave out debugging tools and development dependencies from the production image. Use `npm ci --omit=dev` or the equivalent for your package manager.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .7. Rebuild images regularly with updated dependencies
Older images contain vulnerabilities that have been fixed in newer versions of the base image or installed packages. Plan rebuilds at a fixed time, for example weekly or with every security patch.
docker build --pull -t myapp:$(date +%Y%m%d) .
The `--pull` flag ensures you always fetch the latest version of the base image.
8. Fetch remote files during the build
Download external files during the build instead of copying them from the context. This prevents unnecessarily enlarging the Docker build context.
FROM alpine:3.19
RUN wget -O /usr/local/bin/tool https://example.com/tool.tar.gz \
&& tar -xzf /usr/local/bin/tool.tar.gz -C /usr/local/bin/ \
&& rm /usr/local/bin/tool.tar.gzVerification
Check whether your image is correctly optimized:
Show image size
docker imagesScan for vulnerabilities (Docker Scout or Trivy)
docker scout cves myapp:latest
Show layers and their size
docker history myapp:latest
A production image for a Node.js application should typically be smaller than 200 MB. An image with unnecessary build tools quickly exceeds this.
Common problems
1. Image remains large despite multi-stage build
Check whether you are copying unnecessary files in the runtime stage. Use `docker history
2. Cache is not used on every build
Make sure the order of instructions is correct. Place `COPY package*.json ./` and `RUN npm ci` before `COPY . .`. If your `package.json` changes, the cache becomes invalid for all subsequent layers — that is correct behavior.
3. Alpine base image causes compatibility issues
Alpine uses musl libc instead of glibc. Some native modules do not work with this. Use an alternative small image such as `node:20-slim` (Debian-based) or build with a multi-stage build in which you compile the native module in the build stage.
Regular rebuilding is not a one-time activity but an ongoing process — the security landscape changes constantly, and your images need to move with it.