docker

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.

By Shubhankar Tripathi • • 14 min read

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:

  1. Multi-stage Docker builds

  2. BuildKit cache mounts

  3. 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:

Runtime

Development / Builder Image

Optimized Runtime

Main Optimization

Go API

~1 GB+

Often tens of MB or less

Static binary + minimal runtime

Node.js API

~1 GB+

Often significantly smaller

Production dependencies only

Python FastAPI

~1 GB+

Application-dependent

Slim runtime + production dependencies

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.