How to create lightweight Docker images using multistage builds and minimize layers
Docker & Containers · By Kevin Marcondes · Published on · Updated on · 10 min read
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 optimizationSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian