Writing Efficient Dockerfiles: Tips for Optimizing Image Size and Build Time

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

When I first started using Docker on real projects, I would just copy Dockerfiles from StackOverflow and run them. Build worked, container ran — good enough. But over time, the image ballooned to 1.2GB, CI builds took 8 minutes on every push, and teammates started complaining. That’s when I finally sat down and learned how Dockerfiles actually work.

Looking back, I was making all kinds of basic mistakes: copying the entire node_modules directory into the image, forgetting .dockerignore so the build context ballooned to 800MB, or removing package cache in a separate RUN command thinking it would shrink the image. This post covers what I learned after many failed builds — in the most literal sense. If you’re just getting started, understanding containers, images, and volumes from scratch is a solid foundation before diving into Dockerfile optimization.

How Dockerfiles Work Under the Hood

Understand the layer mechanism and optimizations will start to feel natural. Every instruction in a Dockerfile (RUN, COPY, ADD…) creates a new layer. Docker caches these layers — if a layer hasn’t changed, the next build uses the cache instead of re-executing that step.

The catch: if one layer changes, all subsequent layers are invalidated. This is why builds are slow even when you only changed one line of code.

Order Your Instructions Correctly

The rule I always keep in mind: what changes least often goes at the top.

Wrong example — this is how I used to write Dockerfiles early on:

# WRONG: copy code before installing dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

The problem: every time you change code — even a single comment — the npm install step runs from scratch because the COPY . . layer changed. On a project with hundreds of dependencies, that’s 2–5 minutes wasted every time.

The right approach: separate dependency installation into its own step first:

# CORRECT: copy package files first, install, then copy the rest of the code
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

Now npm ci only reruns when package.json or package-lock.json changes. For regular code edits, this step is cached — and the difference in build speed is immediately noticeable. These same principles carry over when you’re deploying Node.js applications with Docker in production environments.

Multi-Stage Builds — Your Best Weapon for Reducing Image Size

The first time I saw this technique, I couldn’t believe an image could shrink that much. The principle is simple: use a heavy image to build, but only copy the necessary artifacts into the final image — leaving behind the compiler, toolchain, and all intermediate files.

Example with a Go application:

# Stage 1: Build
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 -o main .

# Stage 2: Runtime image
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
CMD ["./main"]

The golang:1.22-alpine image weighs around 300MB. The final image contains only the compiled binary plus the alpine base — roughly 15–20MB total. That’s a 10x reduction. For a deeper look at what’s possible, the article on optimizing Docker image size covers additional techniques beyond multi-stage builds.

The same technique applies to Python (build wheels → copy into a slim image), Java (Maven build → copy JAR), and React (npm build → copy dist into nginx).

Choosing the Right Base Image

ubuntu:latest is the familiar choice — but also the heaviest one. The Ubuntu base image pulls in around 70–80MB along with many tools that have no place in production.

  • Alpine Linux (-alpine): the lightest (~5MB), uses musl libc instead of glibc — can occasionally cause issues with certain C libraries
  • Debian Slim (-slim): a good balance between size and compatibility (~30–80MB)
  • Distroless (Google): no shell, no package manager — highest security, but harder to debug

Recommendation: use -alpine for Go/Node, -slim for Python when native libraries are needed, and distroless for production if your team is comfortable debugging via kubectl exec or sidecar containers.

Don’t Skip .dockerignore

Forgetting to create a .dockerignore file is the most common mistake I see when reviewing Dockerfiles from newcomers. This file excludes unnecessary directories and files before Docker sends the build context to the daemon — without it, everything in the project directory gets sent, including node_modules and the .git folder.

Example .dockerignore for a Node.js project:

node_modules
npm-debug.log
.git
.gitignore
.env
*.md
dist
.dockerignore
Dockerfile
.DS_Store

Without this file, Docker sends the entire node_modules directory (potentially hundreds of MB) to the daemon on every build, even though you’ll be running npm install inside the container anyway. I made this mistake myself — the build context hit 800MB, and every build had an extra 30–40 second wait just for the context upload.

Best Practice: Combine RUN Commands and Clean Up Cache

Every RUN creates a layer. If you install packages and then delete the cache in a separate RUN command, the cache still lives in the previous layer — the image doesn’t get any smaller.

# WRONG: deleting cache in a separate layer has no effect
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*

# CORRECT: combine into one RUN, clean up in the same layer
RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

For Alpine, use:

RUN apk add --no-cache curl git

The --no-cache flag for apk skips creating a cache index entirely, which is even cleaner.

Run Containers as a Non-Root User

By default, containers run as root. If there’s a vulnerability in your app, an attacker gains root inside the container — and depending on the host configuration, may be able to escalate out. This isn’t theoretical: CVE-2019-5736 (runc escape) is a real-world example where root inside a container led to root on the host. For a broader perspective on locking down your containers, the guide on Docker container security best practices is worth reading alongside this. Add this to your Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Create a dedicated user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]

Pin Versions — Never Use latest

The latest tag changes over time. Your build today may be completely different from a build 3 months from now if the base image gets updated.

# Avoid
FROM python:latest

# Prefer: pin to a specific version
FROM python:3.12.3-slim-bookworm

Once you’ve pinned versions, use docker scout or trivy to scan for CVEs regularly and update proactively.

Conclusion

The things I remember most clearly — not from theory, but from getting burned:

  1. Order layers from least-changed to most-changed
  2. Use multi-stage builds for every compiled language
  3. Always add .dockerignore before your first build
  4. Combine RUN commands and clean up cache in the same layer
  5. Run as a non-root user in production
  6. Pin base image versions, never use latest

Every project will have its own trade-offs — sometimes a smaller image means harder debugging, or a faster build requires more complex setup. What matters is understanding why you’re doing something, rather than blindly copying templates the way I used to.

If you want to inspect your own Dockerfile, try running docker history <image_name> to see how much each layer weighs — the heaviest layers usually reveal the problem areas right away.

Share: