Optimizing Docker Images
Learn multi-stage builds, .dockerignore, layer caching, and techniques for smaller images

Buzz presents
big Docker images are slow to build and ship, but a few easy tricks make them teeny tiny. yay!big Docker images are slow to build and ship, but a few easy tricks make them teeny tiny. yay!

Your Docker images work. Cool. But are they any good? A sloppy image can blow past 1GB, take minutes to build and waste bandwidth on every single deploy. With a handful of tricks, that SAME image can shrink to under 100MB and rebuild in seconds. Let's go through the ones that matter most.
Why Image Size Matters#
Big images cause real headaches.
- Slow deployments. Bigger images take longer to push to and pull from registries
- Wasted storage. Every version of a bloated image eats more disk space
- Slower CI/CD. Your build pipeline sits there twiddling its thumbs waiting on huge transfers
- Security risk. More software installed means more spots for vulnerabilities to hide
# Check how big your images are
docker images
# IMAGE DISK USAGE
# myapp:latest 1.2GB <- Too big!
# myapp:optimized 87MB <- Much better!
# (other columns trimmed)Technique 1: Use Alpine or Slim Base Images#
The easiest win is picking a smaller base image. Alpine Linux is a stripped-down distribution that's a tiny fraction of the size of a full Linux image.
# Pick ONE of these. Sizes are the rough download size on Docker Hub
# (images get bigger once they're unpacked on disk)
# Full image: ~400MB download
FROM node:24
# Slim image: ~80MB download
FROM node:24-slim
# Alpine image: ~60MB download (recommended!)
FROM node:24-alpineTechnique 2: Use .dockerignore#
You know how .gitignore keeps junk out of Git? .dockerignore does the same thing for your Docker build. Skip it and Docker ships EVERYTHING in your project folder over to the build.
Make a .dockerignore file in the root of your project.
# Dependencies (will be installed fresh in the container)
node_modules
# Version control
.git
.gitignore
# Environment files (contain secrets!)
.env
.env.local
# IDE and OS files
.vscode
.DS_Store
# Build output and logs
dist
coverage
*.log
# Docker files (not needed inside the image)
Dockerfile
compose.yaml
docker-compose.yml
.dockerignoreWithout a .dockerignore, your local node_modules folder (often 500MB+) gets shipped to Docker on every build. Worse, a copy-everything step that runs after npm install can copy your local version right on top of the fresh install inside the image. Ignoring it fixes both and can chop your build times way down.
Technique 3: Optimize Layer Caching#
Docker caches every build layer. When a layer changes, that layer and everything after it has to be rebuilt. Put things in a smart order and you get way more cache hits.
# BAD ORDER: Any source code change rebuilds dependencies too
FROM node:24-alpine
WORKDIR /app
# Source code changes invalidate this layer
COPY . .
# Must reinstall every time!
RUN npm install
CMD ["node", "server.js"]# GOOD ORDER: Dependencies are cached until package.json changes
FROM node:24-alpine
WORKDIR /app
# Only changes when dependencies change
COPY package*.json ./
# Cached unless package.json changed!
RUN npm install
# Source code changes only rebuild from here
COPY . .
CMD ["node", "server.js"]With the good order, changing your source code only rebuilds that last COPY layer. The npm install layer (which can take minutes) stays cached.
Technique 4: Multi-Stage Builds#
This is the big one. Multi-stage builds let you use one image to BUILD your app and a separate, bare-bones image to RUN it.
# Stage 1: Build (has all dev tools)
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (only has what's needed to run)
FROM node:24-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]Here's what's going on.
- The builder stage installs ALL the dependencies and compiles your TypeScript (or builds your app)
- The production stage starts fresh with only the production dependencies
COPY --from=buildergrabs just the compiled output from the build stage- Dev dependencies, source code and build tools never make it into the final image
Technique 5: Install Only Production Dependencies#
Your production app doesn't need testing frameworks, linters or build tools tagging along.
# Install everything (development)
RUN npm install
# Install only production dependencies (production)
RUN npm ci --omit=devnpm ci --omit=dev skips devDependencies completely, so you end up with a smaller node_modules folder. (You might see older tutorials use --only=production. npm deprecated that flag, so stick with --omit=dev.)
Technique 6: Combine RUN Commands#

Every RUN instruction makes a new layer. Bundle related commands together to cut down on layers and keep the image smaller. (These apt-get lines are for Debian-based images like node:24-slim. Alpine images use apk add --no-cache instead.)
# BAD: 3 separate layers (files from early layers persist even if deleted later)
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# GOOD: 1 layer (cleanup happens in the same layer)
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*Complete Optimized Example#
Here's a fully tuned-up Dockerfile for a TypeScript Node.js app.
# Build stage
FROM node:24-alpine AS builder
WORKDIR /app
# Install dependencies (cached unless package files change)
COPY package.json package-lock.json ./
RUN npm ci
# Build the application
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Production stage
FROM node:24-alpine
WORKDIR /app
# Install production dependencies only
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy only the built output
COPY --from=builder /app/dist ./dist
# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Configure and start
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]Measuring Your Improvements#
Don't just assume it worked. Measure it.
# Check image sizes
docker images myapp
# See layer details
docker history myapp:latest
# Compare before and after
# IMAGE DISK USAGE
# myapp:unoptimized 1.2GB
# myapp:optimized 87MB <- 93% smaller!Optimization Checklist#
Run through this list for every Dockerfile you write.
- Start with an Alpine or slim base image
- Make a
.dockerignorefile (leave out node_modules, .git and .env) - Copy dependency files before source code (for layer caching)
- Use multi-stage builds for compiled languages (TypeScript, Go, Rust)
- Install only production dependencies in the final stage
- Combine related RUN commands to keep layers down
- Run as a non-root user for security
TL;DR#
- Alpine-based images are 5-10x smaller than full images
.dockerignorekeeps files you don't need out of the build- Order your Dockerfile to get the most out of layer caching (dependencies before source code)
- Multi-stage builds keep build tools out of the final production image
- Only install production dependencies in production images
- Combine
RUNcommands to cut layers and keep cleanup in the same layer
What's Next?#
Your images are lean and quick now. So how do you keep them running reliably in production? Next lesson covers the production stuff, like health checks, restart policies, logging, locking things down for security and hooking it all into CI/CD...
This lesson ends with a short activity.
