Multi-Stage Docker Builds for Ultra-Lean Node.js, Go, and Python Containers
Shrink your Docker image sizes from 1.5GB to under 20MB. Master BuildKit cache mounts, security hardening with distroless images, and multi-architecture cross-compilation.
Build Smaller, Faster and More Secure Docker Images
Container images often become much larger than they need to be. A typical development image such as node:20 or golang:1.22 may contain compilers, package managers, build tools, debugging utilities, and other dependencies that are never required when the application is actually running.
The result is predictable:
Slower CI/CD builds
Longer image-pull and deployment times
Higher storage and network costs
Larger container attack surfaces
More unnecessary packages to patch and maintain
A better approach is to keep the build environment separate from the runtime environment.
In this guide, we'll use three techniques:
Multi-stage Docker builds
BuildKit cache mounts
Minimal runtime images such as Distroless
For compiled applications such as Go, this can reduce the final image to only a few megabytes. For Node.js and other interpreted runtimes, the goal is to retain only the files and dependencies required in production.
1. Build an Ultra-Lean Go Container
Go is particularly well suited to container optimization because a Go application can be compiled into a single executable.
Instead of shipping the entire Go toolchain with the application, we can compile the application in one stage and copy only the resulting binary into a minimal runtime image.
Production Dockerfile
# syntax=docker/dockerfile:1.7
# --------------------------------------------------
# Stage 1: Build
# --------------------------------------------------
FROM golang:1.22-alpine AS builder
WORKDIR /src
# Copy dependency files first.
# This allows Docker to reuse this layer when
# application source code changes.
COPY go.mod go.sum ./
# Cache downloaded Go modules
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy application source
COPY . .
# Build a static Linux binary
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
go build \
-ldflags="-s -w" \
-o /bin/server \
./cmd/api
# --------------------------------------------------
# Stage 2: Runtime
# --------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
USER nonroot:nonroot
COPY --from=builder \
--chown=nonroot:nonroot \
/bin/server \
/server
EXPOSE 8080
ENTRYPOINT ["/server"]
Why this image is smaller
The first stage contains everything required to build the application:
Go compiler
Go modules
Build cache
Source code
Build tools
None of those components need to be present in production.
The second stage contains essentially the application binary and the minimal runtime environment required to execute it.
What does -ldflags="-s -w" do?
The following build option:
-ldflags="-s -w"
removes symbol and DWARF debugging information from the compiled binary.
This can significantly reduce binary size without changing the application's normal runtime behavior.
It is important to remember that this also removes useful debugging information, so you should decide whether this trade-off is appropriate for your production build and troubleshooting workflow.
2. Optimize Node.js Container Images
Node.js applications require a slightly different strategy.
Unlike Go, Node.js applications normally need the Node runtime and their production dependencies at runtime.
However, development dependencies such as:
TypeScript
Webpack
Vite
ESLint
Test frameworks
Build plugins
usually don't need to be included in the final container.
This makes Node.js a good candidate for a multi-stage build.
Example Dockerfile
# --------------------------------------------------
# Stage 1: Install dependencies
# --------------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=optional
# --------------------------------------------------
# Stage 2: Build application
# --------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Remove development dependencies
RUN npm prune --production
# --------------------------------------------------
# Stage 3: Production runtime
# --------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create an unprivileged application user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 appuser
COPY --from=builder \
--chown=appuser:nodejs \
/app/dist \
./dist
COPY --from=builder \
--chown=appuser:nodejs \
/app/node_modules \
./node_modules
COPY --from=builder \
--chown=appuser:nodejs \
/app/package.json \
./package.json
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
What's happening here?
The build is divided into three logical stages.
Stage 1 — Dependencies
Only the package manifests are copied first:
COPY package.json package-lock.json ./
This allows Docker to reuse the dependency layer when only application source code changes.
Stage 2 — Build
The application is compiled using the development dependencies.
After the build completes:
npm prune --production
removes dependencies that are not required at runtime.
Stage 3 — Runtime
The final image receives only:
Compiled application files
Production node_modules
package.json
Node.js runtime
The compiler and other build-time tooling stay behind in the builder image.
3. Why BuildKit Cache Mounts Matter
Multi-stage builds reduce the final image size, but BuildKit can also make the build process itself faster.
For example:
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
creates a persistent build cache for downloaded Go modules.
Similarly:
RUN --mount=type=cache,target=/root/.cache/go-build \
go build ...
allows Go's compilation cache to be reused between builds.
This becomes particularly useful in CI/CD environments where the same dependencies are downloaded and compiled repeatedly.
The important distinction is:
Cache mounts improve build performance; multi-stage builds reduce the final runtime image.
They solve different problems and work well together.
4. Distroless Images and Minimal Runtimes
A traditional application image often contains far more than the application actually needs.
For example, a general-purpose Linux image may include:
Shell utilities
Package managers
Networking tools
Editors
Compilers
Debugging utilities
A Distroless image takes a different approach: provide a minimal runtime environment without the usual collection of operating-system utilities.
For example:
FROM gcr.io/distroless/static-debian12:nonroot
For a statically compiled Go binary, this can be an excellent production runtime.
There is also an important security benefit: fewer packages and utilities generally mean a smaller potential attack surface.
However, a minimal image is not a substitute for security practices such as vulnerability scanning, dependency management, least-privilege execution, and regular updates.
5. Run Containers as a Non-Root User
Another useful production practice is avoiding the root user whenever possible.
For example:
USER nonroot:nonroot
or:
USER appuser
Running the application as an unprivileged user limits what the process can do if the application is compromised.
For production containers, combine this with:
Read-only filesystems where practical
Dropped Linux capabilities
Resource limits
Network restrictions
Image vulnerability scanning
Regular dependency updates
6. Example Image Size Comparison
The exact size depends on the application, dependencies, architecture, and image versions, so benchmark your own images rather than treating fixed numbers as universal.
A typical comparison might look like this:
You can inspect the actual image size with:
docker images
For a more detailed view of where the space is being used:
docker history <image-name>
For even deeper analysis, tools such as dive can help identify which layers are consuming the most space.
7. The Bigger Picture
The objective isn't simply to create the smallest possible Docker image.
A production container should balance:
Size + Build Speed + Security + Maintainability
For example:
Application Source
|
v
+---------------------+
| Builder Stage |
|---------------------|
| Compiler |
| Build tools |
| Dev dependencies |
| Source code |
+----------+----------+
|
Build Artifact
|
v
+---------------------+
| Runtime Stage |
|---------------------|
| Application |
| Runtime dependencies |
| Minimal OS/runtime |
+----------+----------+
|
v
Production Image
This separation gives you a cleaner deployment artifact and prevents development tooling from unnecessarily reaching production.
8. Practical Docker Optimization Checklist
Before pushing an image to a production registry, check the following:
Use multi-stage builds
Copy dependency manifests before application source
Use BuildKit cache mounts where they improve build performance
Remove development dependencies from the runtime image
Prefer minimal runtime images
Use Distroless where the application supports it
Run the application as a non-root user
Add a suitable .dockerignore
Scan images for vulnerabilities
Pin or otherwise control important base-image versions
Regularly rebuild images to receive security updates
Measure the final image instead of assuming its size
Conclusion
A production Docker image doesn't need to contain the entire development environment.
The key idea is simple:
Build with everything you need. Run with only what you need.
Multi-stage builds keep compilers and development tools outside the final image. BuildKit cache mounts can accelerate repeated builds, while minimal or Distroless runtime images help reduce unnecessary packages and the associated attack surface.
The result is a cleaner container architecture with potentially:
Faster CI/CD pipelines
Smaller image transfers
Lower registry and network usage
Fewer production dependencies
A reduced container attack surface
Easier operational maintenance
The most important step is to measure your actual application, because image sizes vary considerably depending on dependencies and build configuration.