Top 5 Tips to Shrink and Secure Docker Images
You can shrink Docker images from over 1 GB to under 10 MB by using various techniques. The article talks about five tips that will help achieve this.
Join the DZone community and get the full member experience.
Join For FreeI used to settle for Docker images that were massive, sometimes in GBs. I realized that every megabyte matters, impacting everything from deployment speed and cloud costs to security. With time, I realize there are well-known best practices and advanced techniques to achieve the ultimate goal: a tiny, hardened 10 MB image.
Here’s my comprehensive guide on how I achieve this using minimal base images, mastering layers, and implementing strong security protocols.
1. Minimal Base Images
Your first step should always be to pick a leaner base image(s), which means moving away from bulky defaults like node:latest (often over 1 GB). I think of this as choosing a race car chassis instead of a cargo truck.
Choosing the Right Base for Speed
- Alpine as the starting point: If you opt for the Alpine variant (e.g.,
node:20.10.0-alpine), this immediately cuts the size to about 250 MB. Alpine is purpose-built for containers, stripping out everything but the essentials needed to run the application. - The ultimate finish line — scratch and distroless: To get closer to that 10 MB target, use multi-stage builds (more on that later) and select the smallest possible base for the final production stage.
- Scratch: If your application is a statically compiled binary (like Go), use
FROM scratch. It's an empty base, no OS, no shell. It copies only the binary into it. - Distroless: If your application needs a runtime (like Java or Node) or core C libraries, use a Distroless image. These contain only the necessary runtime files, eliminating the package manager and shell, which are huge security and size liabilities.
Example code (Final Stage using Scratch):
# Stage 2: The Final Image (Absolute minimum base)
FROM scratch
# Copy the compiled binary from the 'builder' stage
COPY --from=builder /app/my_app /usr/bin/my_app
ENTRYPOINT ["/usr/bin/my_app"]
2. Layer Mastery: Speed and Space Efficiency
Every instruction in your Dockerfile creates a layer, and Docker’s ability to cache these layers is crucial for fast build times.
Optimizing Caching Order
You should structure your Dockerfile to maximize cache hits. The rule is simple: put the most stable layers at the top and the most frequently changing layers at the bottom.
- Copy dependencies first: Dependencies change less often than your source code. You should copy only the manifest file (
package.json,requirements.txt) first, install dependencies, and only then copy the full source code. This way, if only your code changes, the long dependency install step is skipped. - Use
.dockerignore: Before any file is copied, ensure to use a comprehensive.dockerignore. This prevents unnecessary files likenode_modules,.gitfolders, and.envfiles from ever entering the build context, saving time and preventing security leaks.
Example code (layer caching strategy):
# 1. Stable layer: Copy just the manifest
COPY package.json package-lock.json ./
# 2. Stable layer: Install dependencies (can be cached longer)
RUN npm install
# 3. Frequently changing layer: Copy all source code
COPY . .
Squashing Layers and Cleaning Up
Ironically, deleting a file in a new layer doesn’t shrink the image; it just adds a “deletion marker” because the previous layers are immutable.
To truly save space, you should combine installation and cleanup into a single RUN command using the && operator.
Example code (installation and cleanup in one layer):
# Single RUN command ensures the cache is cleaned before the layer
# is finalized.
RUN apt-get update \
&& apt-get install -y --no-install-recommends my-package \
# The essential cleanup steps:
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
3. The Final Cut: Multi-Stage Builds
This, in my opinion, is the most powerful technique. Multi-stage builds are the only way to genuinely discard everything used for building (compilers, dependencies, intermediate files) that isn’t needed for running.
- The builder stage: This stage uses a larger base (like
node:alpine) to fetch dependencies, compile, and create the production artifacts (e.g., static HTML files). - The final stage: This stage uses a tiny, minimal base (like
nginx:alpineorscratch) and only copies the final, lean artifacts from the builder stage using the--from=builderdirective. Everything else, like Node.js, NPM, and the build-time dependencies, is thrown away.
Example code (multi-stage build):
# --- Stage 1: Builder ---
FROM node:20.10.0-slim AS builder
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build # Creates 'dist' folder
# --- Stage 2: Production (Tiny image) ---
FROM nginx:alpine
# Only copy the final, optimized build output from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Image size is now tiny (e.g., 57MB)
4. Hardening the Image: Security Is Size
A smaller image is inherently more secure because it has less surface area for attack. You should always implement these two crucial steps to lock down your containers:
Run as a Non-Root User
Running processes as root is a critical vulnerability. If an attacker compromises the container, they gain root access within that environment. You should always create and switch to a dedicated, unprivileged user.
# Install dependencies as root (if needed)
FROM node:20.10.0-slim
# Create a non-root user
RUN adduser --disabled-password --gecos "" appuser
# Switch to the non-root user for all runtime commands
USER appuser
No Secrets in Layers: BuildKit
You should never use ARG or ENV for secrets during the build, as they are recorded in the image history. Instead, use Docker's BuildKit secrets feature to temporarily mount a secret file during the specific RUN command that needs it, ensuring the secret never touches a persistent layer.
# Build with: docker build --secret id=npmkey,src=~/.npmrc .
# The secret is only accessible during this RUN command, then it disappears.
RUN --mount=type=secret,id=npmkey npm install
5. Optimization Tools
I rely on these tools to verify my process and find hidden bloat:
- Dive: I use this image explorer to visually inspect every layer. It quickly highlights where files were added and, more importantly, where size was not saved (i.e., when I forgot to clean a cache).
- Slim.AI: For the final optimization push, I use Slim to automatically analyze my application at runtime and build a truly minimal version. It often helps me eliminate those last few unnecessary libraries and tools that get me down to the 10 MB final size.
Hope these five tips will help you in reducing image size and securing your containers as well.
Opinions expressed by DZone contributors are their own.
Comments