How to create lightweight Docker images using multistage builds and minimize layers

Docker & Containers · By · Published on · Updated on · 10 min read

Person seen from behind in an office, large monitor displaying code in an editor
Monitor with code editor on an office desk, ideal for tutorials and technical guides. Photo: ThisIsEngineering / Pexels

This guide shows, with hands‑on examples, how to create lightweight Docker images using multistage builds and minimize layers. You’ll find Dockerfile examples for Node.js and Go, BuildKit cache techniques, .dockerignore tips, distroless usage, and how to measure build size and time to validate improvements.

Prerequisites and test scenario (Node.js)

You need Docker installed and running. If you don’t have it yet, follow this guide: How to Install Docker and Docker Compose on Linux. We’ll create a simple Node.js app to measure before and after using multistage builds.

We’ll also use a .dockerignore to avoid copying unnecessary files. Each step is commented so you can reproduce and adapt it to your project.

  • Docker installed and a user allowed to run containers
  • Internet connection to download base images
  • Text editor and terminal
mkdir -p nodeapp && cd nodeapp

cat > package.json <<'EOF'
{
  "name": "nodeapp",
  "private": true,
  "type": "module",
  "scripts": { "start": "node server.js" }
}
EOF

cat > server.js <<'EOF'
import http from 'node:http'
const port = process.env.PORT || 3000
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('ok')
})
server.listen(port, () => console.log(`listening on ${port}`))
EOF

cat > .dockerignore <<'EOF'
node_modules
npm-cache
.git
Dockerfile*
.dockerignore
*.log
EOF

Layers, cache and BuildKit in a nutshell

Each instruction in the Dockerfile creates a layer. Fewer, well‑designed layers help cache efficiency and reduce pull size. The cache prevents redoing steps when nothing changed. BuildKit improves parallelism, offers more granular cache and supports cache mounts for package managers.

Let’s enable BuildKit to take advantage of advanced cache features and clearer build output.

  • COPY stable files before volatile ones to increase cache hits
  • Grouping related commands reduces layers
  • BuildKit cache mounts speed up npm, pip and go
# Ative o BuildKit no shell atual
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
# Opcional: usar builder moderno
docker buildx create --use --name fastbuilder 2>/dev/null || docker buildx use fastbuilder

Naive Dockerfile (no multistage) for comparison

Let’s create a basic Dockerfile that copies everything and installs dependencies. It works, but is larger than necessary. Later we’ll compare it with the multistage version.

If you’re new to Dockerfiles, this other guide gives the initial context: Create Your First Dockerfile from Scratch.

  • Full official base increases final image size
  • COPY . . brings unnecessary files if .dockerignore isn’t configured
  • Accidentally installing devDependencies inflates the image
cat > Dockerfile.naive <<'EOF'
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
EOF

# Build inicial
docker build -f Dockerfile.naive -t nodeapp:naive .
# Ver o tamanho
docker images nodeapp:naive

Lean multistage build for Node.js (with distroless)

Now a multistage Dockerfile. First we resolve dependencies in a build image. Then we copy only what’s necessary into a minimal final image based on distroless, which has no shell.

Distroless images reduce attack surface and size, but don’t allow an interactive shell. Use logs and health probes for diagnostics.

  • Separating build deps from runtime avoids carrying build tools
  • distroless removes the shell and package manager from the runtime
  • Selective COPY ensures only essentials are in the final stage
cat > Dockerfile.multi <<'EOF'
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Se usar TypeScript/Vite/etc., descomente sua etapa de build
# RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
# distroless executa "node" como entrypoint; indique o script
CMD ["server.js"]
EOF

# Build otimizado
docker build -f Dockerfile.multi -t nodeapp:multi .
# Checar tamanho
docker images | grep nodeapp

Extra example: Go binary in a minimal image

For compiled languages the gain is even greater. Build the binary in a builder image and copy only the executable to a minimal static distroless base.

Running as a non‑root user is best practice. Adjust GOOS/GOARCH for your deployment target.

  • Disabling CGO produces a static, portable binary
  • Use module cache to speed repeated builds
  • ENTRYPOINT directly to the binary simplifies runtime
mkdir -p ../goapp && cd ../goapp
cat > go.mod <<'EOF'
module example.com/goapp

go 1.22
EOF

cat > main.go <<'EOF'
package main
import (
  "fmt"
  "net/http"
)
func main() {
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "ok")
  })
  http.ListenAndServe(":8080", nil)
}
EOF

cat > Dockerfile.go <<'EOF'
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod .
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]
EOF

# Build e ver tamanho
docker build -f Dockerfile.go -t goapp:distroless .
docker images goapp:distroless
cd -

Techniques to minimize layers and files

Besides multistage, combine commands and ignore files in the build context. With BuildKit, cache mounts speed up package managers. The table summarizes when to use each technique.

If your CI/CD runs on GitHub Actions, integrate build cache for consistent gains: How to set up an automatic deploy pipeline.

  • Combine RUN with && and clean in the same layer
  • Use .dockerignore to avoid sending build context garbage to the daemon
  • Place COPY package*.json before COPY . to maximize cache hits
  • Cache npm/pip/go to speed repeated builds
Technique When to use Notes
.dockerignore Always Reduces build context and unnecessary cache invalidations
Combined RUN Debian/Alpine bases Cleaning in the same RUN avoids layers holding package caches
Cache mount (BuildKit) npm/pip/go Reuses caches across builds without adding them to the final image
distroless/scratch Production Smallest attack surface; no shell — rely on logs and healthchecks
Non‑root user Always Reduces the impact of runtime vulnerabilities
# Exemplo de RUN combinado (Debian/Ubuntu base)
cat > Dockerfile.snippets <<'EOF'
FROM debian:stable-slim AS build
RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential ca-certificates \
 && rm -rf /var/lib/apt/lists/*

# BuildKit cache de npm
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# .dockerignore típico
# node_modules\n.git\nDockerfile*\n.dockerignore\n*.log\nbuild\ndist\ncoverage\n
EOF

How to measure size, layers and cache gains

Let’s compare builds and inspect layers. Numbers vary by machine, network and local cache. Measure without cache for a baseline, then with cache to see the difference.

You can also manage containers and view metrics via a UI using Portainer: How to Use Portainer for Docker.

  • Measure without cache for a baseline
  • Repeat with cache to see improvements
  • Use docker history to inspect layers and sizes
# Na pasta nodeapp
cd nodeapp

# Linha de base (sem cache)
/usr/bin/time -f "naive sem cache: %E" docker build --no-cache -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi sem cache: %E" docker build --no-cache -f Dockerfile.multi -t nodeapp:multi .

docker images | grep nodeapp
# Exemplo (pode variar):
# nodeapp   multi   29MB
# nodeapp   naive   180MB

# Com cache (repetir build)
/usr/bin/time -f "naive com cache: %E" docker build -f Dockerfile.naive -t nodeapp:naive .
/usr/bin/time -f "multi com cache: %E" docker build -f Dockerfile.multi -t nodeapp:multi .

# Inspecione camadas
docker history nodeapp:naive | head -n 10
docker history nodeapp:multi | head -n 10

How to confirm it works and troubleshoot

Start the containers and make an HTTP request. Remember: distroless images have no shell. Use logs, healthchecks and environment variables for debugging.

If something fails, check ports, file permissions and environment variables. Adjust COPY and .dockerignore to include only what’s necessary.

  • EACCES error? Check permissions and non‑root user
  • No shell in distroless: use docker logs and health probes
  • Build failing? Confirm COPY order and cache behavior
  • Wrong port? Check EXPOSE and -p mapping
# Node (porta 3000)
docker run -d --rm -p 3000:3000 --name nodeapp nodeapp:multi
curl -sf http://localhost:3000 && echo "\nOK"
docker logs nodeapp | tail -n +1

# Go (porta 8080)
docker run -d --rm -p 8080:8080 --name goapp goapp:distroless
curl -sf http://localhost:8080 && echo "\nOK"

docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'

Quick checklist before committing

Before versioning your Dockerfile, validate these points. They prevent slow builds and heavy images going forward. For deeper reading about runtime infrastructure, also see: Docker vs VMs for high availability.

  • .dockerignore excludes node_modules, logs and build directories
  • COPY package*.json before COPY .
  • Use multistage and a minimal final image (slim/distroless)
  • No build tools in the final stage
  • Run as non‑root when possible
  • BuildKit enabled and caches working

Frequently asked questions

Why do multistage builds produce smaller images?

Because you separate the build phase (with compilers and tools) from the runtime. In the final stage you copy only the necessary artifacts, so build libraries and temporary caches don’t end up in the production image.

When should I use distroless instead of alpine or slim?

Use distroless if you want a smaller attack surface and minimal image size and don’t need an interactive shell at runtime. It’s ideal for services with logging and healthchecks. If you need interactive debugging inside the container, prefer alpine or slim.

Is Alpine always smaller?

Often yes, but not always. For Node.js and Python, differences in libraries and compatibility can affect size and performance. Test alpine and slim, and compare build time, image size and runtime behavior before deciding.

How do I use dependency cache with BuildKit?

Add RUN instructions using --mount=type=cache pointing to the tool’s cache directory (npm, pip, go). Keep your manifest files (package.json, go.mod) copied before the code to maximize cache hits.

Can I use scratch as the final image?

Yes, for static binaries (e.g. Go with CGO disabled). The scratch image is empty and very small. However, you won’t have shell, certificates or timezone data. If you need TLS certificates, copy them during the build or use a static distroless that includes them.

How can I inspect layers and see what weighs the most?

Use docker history to view layers and sizes. Combine it with docker system df -v to understand local cache usage. Reorganize your Dockerfile to maximize cache hits and remove build artifacts that aren’t needed in the final stage.

Want a review of your Dockerfiles and pipelines?

If you’d like hands‑on help reducing image size and speeding builds with multistage and BuildKit, we can discuss your specific case.

Talk about build optimization

See also