Docker Multi-Stage Build Secrets: Cut Your Production Images from 1.2GB to 45MB
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 buildTo 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 -fSummary 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=secretfor 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.
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
PostgreSQL and MySQL Indexing Secrets: Speed Up Slow Queries on 10-Million Row Tables
Speed up slow queries on 10-million row tables by creating composite indexes that follow the Equality-Sort-Range rule and cutting index size with partial indexing. These strategies replace heavy full table scans with instant index seeks to drop query latency from seconds down to milliseconds.
Cursor AI and GitHub Copilot Prompting Rules That Write Bug-Free Code on the First Try
To get bug-free code on the first try with Cursor AI and GitHub Copilot, feed them strict type definitions, narrow scopes, and test assertions before writing logic. Treating AI like a junior developer who needs exact guardrails stops hallucinations and syntax errors before they start. Here are the core prompting rules and system setups that work every time.
Building Resilient REST APIs in Laravel: 6 Idempotency and Rate-Limit Secrets
A resilient REST API handles network drops and double clicks without corrupting data or crashing servers. In Laravel, you achieve this by pairing atomic cache locks with custom idempotency keys and dynamic rate limiters. Here are six simple secrets to protect your endpoints right now.