docker-optimizacion-cache-multi-stage

Optimizing Docker Images

  • 5 min

The optimization of images in Docker consists of making images faster to build and smaller to run.

You’ve learned to write your own Dockerfile. You’ve run the build command and your application works perfectly. You’re proud, but soon you realize two problems that emerge as the project grows:

  1. Speed: Every time you change a single line of code or fix a typo, the docker build command takes 5 minutes to re-download and re-install absolutely all the project’s libraries.
  2. Size: Your final image of a simple “Hello World” application ends up taking up 800 MB or even gigabytes of space.

How is it possible that something so simple takes up so much hard drive space? And why does the engine repeat processes it had already done before?

In this article, we are going to improve our Dockerfiles without changing the application. We are going to learn how to leverage the cache for near-instant builds and how to use Multi-stage Builds to create much smaller images.

The layer cache

Remember that an image is made of stacked layers. When you run docker build, Docker goes through your Dockerfile instruction by instruction.

The key idea is this: if an instruction hasn’t changed and the files it uses haven’t either, Docker reuses the layer it already has stored in cache.

If Docker detects a change in a line (for example, you have modified the source code), it invalidates the cache for that line and for all subsequent lines.

The common mistake: Copying everything at the beginning

Look at this typical Dockerfile when we start out:

# BAD CACHE USAGE ❌
FROM node:18
WORKDIR /app
COPY . .            # Here you copy your code
RUN npm install     # Here you install dependencies (slow)
CMD ["node", "app.js"]
Copied!

What is the problem? The source code (.) changes constantly (every time you edit a file).

  1. Docker reaches COPY . . and sees that the code has changed ➡️ Breaks the cache.
  2. It reaches RUN npm install. Since the previous layer changed, this one is forced to execute again.

Result: Even if you only changed a console.log and didn’t touch the libraries, Docker re-downloads the entire internet (npm install) every single time.

We must copy first what changes the least (the dependency definitions) and then what changes the most (the code).

# GOOD CACHE USAGE ✅
FROM node:18
WORKDIR /app

# 1. We copy ONLY the package definition files
COPY package.json package-lock.json .

# 2. We install. This layer will be cached and won't repeat unless we change dependencies.
RUN npm ci

# 3. NOW we copy the source code
COPY . .

CMD ["node", "app.js"]
Copied!

With this change, if you modify your code:

  1. Docker sees that package.json hasn’t changed ➡️ Reuses cache (Instant).
  2. Docker sees that RUN npm ci doesn’t need to run ➡️ Reuses cache (Instant).
  3. Only the final COPY . . is re-executed.

Your build goes from taking 3 minutes to taking 0.5 seconds.

Multi-stage Builds (Reducing size)

Now let’s tackle size. To compile an application (Java, Go, C#, or a React frontend), you need many tools: compilers (GCC), SDKs, package managers (Maven, Node)…

But to run the application, you only need the binary or the static files. If you use a base image with all the tools (FROM golang or FROM node), your final image will weigh gigabytes because it carries the compiler inside, even though you don’t need it anymore.

The solution is Multi-stage Builds.

This technique allows us to use a “dirty and heavy” image to compile, and then copy only the result to a “clean and lightweight” image for production. All in a single Dockerfile.

Practical Example: A React/Web App

Imagine you have a React website. You need Node.js to compile it (npm run build), but to serve it, you only need a lightweight web server (Nginx).

# --- STAGE 1: BUILDER (The worker) ---
# We give it an alias "builder" to refer to it later
FROM node:18 AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci
COPY . .
# This creates the /app/dist folder with optimized HTML/CSS/JS
RUN npm run build

# --- STAGE 2: RUNNER (The final product) ---
# We start FROM SCRATCH with an empty and lightweight image
FROM nginx:alpine

# Copy ONLY the compiled files from the previous stage
# --from=builder allows us to "steal" files from the other image
COPY --from=builder /app/dist /usr/share/nginx/html

# No need to install Node, NPM, or have the original source code.
# We only have Nginx and the static files.
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Copied!

Results

MethodBase ImageFinal Size
Traditionalnode:18~ 1.1 GB
Multi-stagenginx:alpine~ 25 MB

We have reduced the size by 97%.

  • Less disk space.
  • Faster downloads on the server.
  • More security: By not having compilers or development tools in the final image, if a hacker gets in, they have no tools (npm, gcc, git) to escalate privileges.