Coding's Art — #1 Web Development & Custom Web Design in Jagraon
DevOps & Cloud • 7 min read • 11 Oct, 2026

Docker Multi-Stage Build Secrets: Cut Your Production Images from 1.2GB to 45MB

Docker Multi-Stage Build Secrets: Cut Your Production Images from 1.2GB to 45MB - Coding Tutorial Guide

The Hidden Cost of 1.2GB Docker Images

Huge Docker images slow down your entire team. When your production image weighs 1.2GB, every deployment takes forever. Your CI/CD runner spends minutes pulling layers. Your cloud registry bill climbs every month. Worse, your production server wastes precious disk space and memory unpacking tools that your live app will never run.

Most developers do not realize why their images get so heavy. When you run npm install, build a Go binary, or compile Rust code, you drag along compilers, test runners, documentation, and cached files. None of those belong in production. You only need the final compiled app and the bare runtime to execute it.

We can solve this problem once and for all using Docker multi-stage builds. In this tutorial, you will learn how to shrink a massive image down to just 45MB. We will also cover how to handle build secrets safely so private tokens never end up inside public image layers.

How Multi-Stage Builds Actually Work

A standard Dockerfile uses a single base image. Everything you run inside that Dockerfile stays in the final layer stack forever. Even if you run rm -rf node_modules in a later step, earlier layers still retain the files on disk.

A multi-stage build changes the game. It allows you to use multiple FROM lines in a single Dockerfile. Each FROM instruction starts a fresh build stage with a clean file system.

You can name these stages. You use the first stage to install compilers, fetch dependencies, and build your assets. Then, you spin up a tiny second stage. You use the COPY --from command to pull only the compiled files into this final stage. Docker throws away the entire builder stage when the job finishes.

The Bloated Dockerfile vs The Multi-Stage Dockerfile

Let us look at a typical Node.js application with TypeScript. Beginners often write a Dockerfile like this:

# The Bloated Approach (Result: ~1.2GB)
FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

This single-stage container ships with the full Debian operating system, Python, C++ build tools, all your source TypeScript files, and thousands of heavy development packages. It is slow, fat, and packed with security vulnerabilities.

Here is how you rewrite that exact same application using a multi-stage build. This cuts the final image down below 50MB:

# Stage 1: Build the code
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Remove development packages to keep only production items
RUN npm prune --production

# Stage 2: Minimal Production Runner
FROM node:20-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production

# Copy only the files we need to run the app
COPY package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

# Run as a non-root user for basic security
USER node

EXPOSE 3000
CMD ["node", "dist/index.js"]

In this refactored file, the first stage does the heavy lifting. The second stage starts with a fresh, tiny Alpine image. It copies only the compiled JavaScript and the production dependencies. All development tools disappear completely.

Safely Using Secrets Without Baking Them into Layers

Many apps need a GitHub token, an npm token, or a private SSH key to fetch private code during the build. If you pass these secrets using regular ARG or ENV flags, Docker saves them directly inside the image metadata. Anyone with read access to the image can inspect the history and steal your keys.

Docker BuildKit fixes this with mounted secrets. Mounted secrets allow you to access private keys during a RUN step without saving the secret to any file layer.

# Syntax flag required for BuildKit secret features
# syntax=docker/dockerfile:1.4

FROM node:20-alpine AS builder
WORKDIR /app

COPY package*.json ./

# Mount the secret directly during the install command
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

COPY . .
RUN npm run build

To build this image with your local secret file, run this terminal command:

DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t my-app:latest .

The secret file stays mounted in memory only while npm ci runs. The moment that step finishes, the secret vanishes. It never touches your disk layers or final image.

Golang and Rust: Shrinking from 1GB to Under 20MB

If you write compiled languages like Go or Rust, you can do even better. These languages create self-contained binary files. That means your final production container does not even need an operating system.

You can compile your app in an official Go builder image, and copy the binary directly into an empty scratch container or Google distroless image:

# Stage 1: Compile the binary
FROM golang:1.22-alpine AS builder

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# Build a static binary without external C library dependencies
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server .

# Stage 2: Empty Scratch Image (Result: ~15MB)
FROM scratch

# Import root certificates so the binary can make HTTPS calls
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

This build strips debug symbols using -ldflags="-s -w" and places the binary into scratch. The final image size drops down to around 15MB, containing nothing except the binary and SSL certificates.

Common Pitfalls and How to Fix Them

1. Broken Shared Libraries on Alpine (musl vs glibc)

Alpine Linux uses musl instead of the standard GNU C library (glibc). If you compile a binary that links against glibc and try to run it on Alpine, it will fail with an error like file not found. Always ensure your compiler uses static linking (such as CGO_ENABLED=0 in Go) if you plan to run it on Alpine or Scratch.

2. Cache Invalidation Slowing Down Builds

Docker caches layers from top to bottom. If you copy all your source files before running your package manager install, Docker re-runs the entire install on every single code edit. Always copy your lock files first, run your install, and then copy the source code.

3. Orphaned Build Cache Consuming Host Disk

When you use multi-stage builds heavily, temporary builder stages can leave behind dangling cache data on your development machine. Run this command weekly to clear old build stages without deleting your active containers:

docker builder prune -f

Summary Checklist

  • Always split your Dockerfile into at least two stages: one for building, one for running.
  • Order instructions carefully: copy dependency files before source code to maximize layer caching.
  • Use --mount=type=secret for private tokens instead of build arguments.
  • Prune development dependencies before copying files to the runner stage.
  • Choose small base images like Alpine or Distroless for your production runner.
" ,

Frequently Asked Questions

Q.

Q.

Q.

HS

Hardeep Singh

Lead AI & Full-Stack Architect | Founder, Coding's Art

Hardeep is a senior software engineer and published author specializing in Python automation, enterprise Laravel systems, and autonomous AI agents. With over 8 years of hands-on experience, he designs robust digital solutions and publishes practical engineering playbooks for developers and technical leaders across India and globally.

Related Developer Guides

Chat with AI Assistant