Dockerfile Optimization â Multi-Stage Builds, Image Size Reduction, Layer Caching, and Security
In this tutorial, you'll learn about Dockerfile Optimization. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Dockerfile optimization reduces image size, improves build speed through layer caching, and enhances security by minimizing the attack surface -- transforming multi-hundred-megabyte images into lean, production-ready artifacts that deploy faster and have fewer vulnerabilities.
What You'll Learn
Why It Matters
A typical unoptimized Docker image for a Node.js application is 1.2GB and contains build tools (npm, gcc, python), development dependencies, cached package archives, and OS-level package managers. This bloated image takes minutes to pull, fills up registry storage, and includes hundreds of unnecessary files that increase the attack surface. Optimized images are under 150MB, pull in seconds, and contain only the runtime essentials.
Real-World Use
DodaTech reduced the Durga Antivirus Pro API image from 1.1GB to 134MB using multi-stage builds and distroless base images. Build time dropped from 8 minutes to 2.5 minutes thanks to layer caching. Container startup time improved from 12 seconds to 3 seconds, and monthly registry storage costs decreased by 80 percent.
flowchart TD
A["Dockerfile"] --> B["Stage 1: Builder"]
B --> C["Base: node:20-bookworm"]
C --> D["Install build tools"]
D --> E["Copy package.json"]
E --> F["npm ci (install deps)"]
F --> G["Copy source code"]
G --> H["npm run build"]
H --> I["Stage 2: Runtime"]
I --> J["Base: gcr.io/distroless/nodejs20"]
J --> K["Copy --from=builder /app/dist"]
J --> L["Copy --from=builder /app/node_modules"]
K --> M["USER nonroot"]
L --> M
M --> N["Final Image: ~150MB"]
style A fill:#2496ED,color:#fff
style B fill:#326CE5,color:#fff
style I fill:#269539,color:#fff
style N fill:#2496ED,color:#fff
Prerequisites: Basic Docker knowledge, Docker installed locally, and familiarity with your application's build process (npm, pip, go build, etc.).
Multi-Stage Builds
Multi-stage builds use multiple FROM statements in a single Dockerfile. Each stage can use a different base image. Only the final stage determines the image size.
# Dockerfile -- optimized Node.js build
# Stage 1: Install ALL dependencies (including dev)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /tmp/node_modules-prod && \
npm ci
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "server.js"]
Expected behavior: The deps stage installs all npm dependencies. The <a href="/design-patterns/builder/">builder</a> stage compiles the application. The runner stage copies only the compiled output and production dependencies -- no build tools, no node_modules/.cache, no source maps (unless explicitly kept). The final image runs as a non-root user.
# Build the optimized image
docker build -t myapp:optimized .
# Expected output:
# Step 1/20 : FROM node:20-alpine AS deps
# ...
# Successfully built abc123
# Successfully tagged myapp:optimized
# Compare sizes
docker images myapp
# Expected output:
# REPOSITORY TAG IMAGE ID SIZE
# myapp unoptimized def456 1.2GB
# myapp optimized abc123 145MB
Layer Caching Best Practices
Each RUN, COPY, and ADD instruction creates a layer. Docker caches layers and reuses them when the instruction and context have not changed.
# Dockerfile -- optimized for caching
FROM python:3.12-slim AS builder
WORKDIR /app
# Step 1: Install system dependencies (rarely changes)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Step 2: Copy only dependency files first (changes infrequently)
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Step 3: Copy source code (changes frequently -- last layer)
COPY . .
# Step 4: Production stage
FROM python:3.12-slim AS production
WORKDIR /app
# Copy Python packages from builder
COPY --from=builder /root/.local /root/.local
# Copy application code
COPY --from=builder /app /app
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]
Expected behavior: If requirements.txt has not changed, Docker reuses the cached layer from step 2, which takes seconds. If only source code changes (step 3), only that small layer is rebuilt. This is dramatically faster than rebuilding all layers.
| Optimization | Before | After | Savings |
|---|---|---|---|
| Image size | 1.2 GB | 145 MB | 88% |
| Build time (no cache) | 8 min | 3 min | 63% |
| Build time (with cache) | 8 min | 45 sec | 91% |
| Pull time (100Mbps) | 96 sec | 12 sec | 88% |
Distroless and Scratch Images
Distroless images contain only the application and its runtime dependencies -- no shell, no package manager, no utilities. Scratch images contain nothing.
# Dockerfile -- Go application with scratch base
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
Expected output: The final image is approximately 15MB for a Go HTTP server. A scratch image has no shell, no ls, no curl, no package manager. This dramatically reduces the attack surface -- there is no shell to exploit for Command Injection.
# Verify the distroless image
docker run -d --name go-app -p 8080:8080 go-server:latest
# Check the image size
docker images go-server
# Expected output:
# REPOSITORY TAG IMAGE ID SIZE
# go-server latest abc123 15.2MB
# Send a test request
curl http://localhost:8080/healthz
# Expected output:
# {"status":"ok"}
Security Hardening
# Dockerfile -- security-hardened Node.js
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy only production artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Security: remove write permissions for runtime user
RUN chown -R appuser:appgroup /app && \
chmod -R 755 /app && \
chmod -R 555 /app/dist
# Switch to non-root user
USER appuser
# Security headers
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/healthz', r => {process.exit(r.statusCode === 200 ? 0 : 1)})"
CMD ["node", "dist/server.js"]
Key security modifications:
- Non-root user with UID 1001
- Distribution files set to read-only (555)
- Write permissions removed from runtime user
- HEALTHCHECK instruction integrated
NODE_ENV=productionto disable development features
Common Errors
COPY . / in the first instruction: Copying the entire source directory before installing dependencies invalidates the cache on every file change. Always copy package manifests first, install dependencies, then copy the rest of the source.
Keeping the package manager cache:
apt-get installwithoutrm -rf /var/lib/apt/lists/*leaves package lists in the image.npm installwithout--no-cacheornpm cache clean --forceleaves the npm cache. Each unnecessary file increases the image size.Using
latesttag in FROM:FROM node:latestproduces different images depending on when the Dockerfile is built. Always pin to a specific version:FROM node:20-alpineorFROM python:3.12-slim-bookworm.Running as root inside the container: If an attacker exploits the application, they gain root access in the container, enabling container escape attempts. Always create and switch to a non-root user with
USERdirective in the final stage.Exposing secrets during build: ARG and ENV instructions are visible in
docker history. Passing an API key as--build-arg API_KEY=secretleaves the key visible in the image history. Use build secrets with--secretflag instead.
Practice Questions
Why does the order of COPY instructions matter for layer caching? Answer: Docker caches each layer and invalidates the cache for all subsequent layers when a layer changes. Copying the dependency manifest first (rarely changes) creates a stable cache key. Copying the source code last (changes frequently) minimizes rebuild scope to only the source layer.
What is the difference between
npm ciandnpm installin a Dockerfile? Answer:npm ciinstalls exact versions frompackage-lock.jsonwithout resolving dependencies, which is faster and produces reproducible builds.npm installresolves versions, updates lockfile, and is slower. In CI/CD,npm ciis the correct choice.How does a scratch base image improve security compared to Alpine? Answer: Scratch has no shell, no utilities, no package manager, no filesystem -- nothing. If an attacker gains code execution, there is no
bash,curl, orwgetto download exploit tools. Alpine at least hasshandapk, which can be used for post-exploitation.Why should you combine
RUN apt-get updateandapt-get installin a single RUN instruction? Answer: Docker caches layers independently. Ifapt-get updateis a separate layer, it is cached and may install outdated packages that were updated after the image was built. Combining them in one RUN ensures a fresh update before every install.
Challenge
Take an unoptimized Dockerfile (provided below) and optimize it: reduce the image from ~1.2GB to under 200MB, implement multi-stage builds, optimize layer ordering for caching, switch to a non-root user, remove all development dependencies from the final image, add a HEALTHCHECK, and verify the application still runs correctly.
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
Mini Project
Build a complete CI/CD pipeline for Docker image optimization: create a Repository with three Microservices (Node.js API, Python ML service, Go CLI tool), write optimized Dockerfiles for each using multi-stage builds, distroless or scratch base images, and layer caching optimization, set up GitHub Actions that builds all three images with cache-from/cache-to for BuildKit caching, integrates Trivy vulnerability scanning with a gate that blocks if critical CVEs exist, signs the images with Docker Content Trust (Notation), pushes to a registry with a semantic version tag, and deploys the images to Kubernetes. Measure and document the size reduction and build time improvement for each service.
Related Resources
| Resource | Description |
|---|---|
| Docker Compose | Multi-container Orchestration |
| Container Security | Securing container images |
| Kubernetes Deployments | Deploying optimized images |
| CI/CD Pipelines | Automating image builds |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro