How to Build a Smaller, More Secure Dockerfile
Right base image, multi-stage builds, non-root user, and optimized layers: how to make your Dockerfile smaller and more secure in practice.
You write a Dockerfile that works, builds, ships an image — and then discover it weighs 1.2 GB, runs as root, and carries an entire C compiler it will never use in production. Worse: your pipeline's security scanner flags 80 CVEs inherited from packages you didn't even know were there. The container works, but it's slow to pull, expensive to store, and an open door waiting for someone to walk through.
The good news is that slimming down and hardening a Dockerfile doesn't mean rewriting the application. A handful of decisions — which base image to use, how to separate build from runtime, which user to run as — add up to cut size by 80% and shrink the attack surface dramatically. This guide shows how to do it in practice, with real examples, and when each technique actually pays off.
Why size and security go hand in hand
It might look like reducing size and increasing security are separate goals, but they're the same move seen from two angles. Every package inside the image is simultaneously weight on disk and a line of code that might carry a vulnerability. An apt-get install build-essential you forgot to remove doesn't just add hundreds of megabytes — it hands an attacker a gcc, a make, and system libraries ready to compile an exploit inside the container.
The mental rule is simple: anything not required to run the application shouldn't be in the final image. Build tools, source code, test files, package manager caches, docs — none of that runs in production. The smaller the image, the less there is to go wrong.
If you're still assembling your first container and want to understand the concepts of image, layer, and container before optimizing, the Docker for beginners guide is worth a read — here we assume you already have a working Dockerfile and want to make it lean and secure.
Start with the right base image
The base image choice sets the floor for both your size and your attack surface. A full node:20 brings all of Debian, with bash, curl, git, and dozens of utilities. The node:20-slim variant trims much of that. The node:20-alpine uses Alpine Linux with musl libc and lands around 50 MB versus ~400 MB for the full one.
# Heavy: full Debian, ~1 GB with dependencies
FROM node:20
# Lean: ~200 MB
FROM node:20-slim
# Minimal: ~50 MB base (watch out for musl libc)
FROM node:20-alpine
Alpine is tempting, but it has a catch: it uses musl instead of glibc, and some native dependencies (database drivers, libraries relying on prebuilt binaries) break or require recompilation. For most web projects -slim is the best balance of size and compatibility. Save Alpine for when you've measured and confirmed the megabytes saved are worth it.
At the security extreme are Google's distroless images: they contain only the language runtime and your application — no shell, no package manager, no ls. If an attacker breaks in, there isn't even a sh to run commands. The cost is harder debugging, since you can't open a terminal inside the container.
Separate build from runtime with multi-stage
This is by far the highest-impact technique. The idea is to use a fat, tool-rich image only to compile/transpile the app — then copy just the final result into a clean, minimal image. The build stage's layers are discarded; only the last stage becomes the published image.
# Stage 1: build — has all the tools
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime — clean and minimal
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Notice what happened: the devDependencies, TypeScript, source code, and npm cache all stayed in the build stage and never reach the final image. The --from=build copies only the dist/ folder. In compiled projects like Go or Rust the gain is even more brutal: you compile a static binary in the first stage and copy it into a FROM scratch (a literally empty image), ending with an image of just a few megabytes.
Don't run as root
By default, the process inside the container runs as root (UID 0). If there's a flaw in the application that allows code execution, the attacker starts with maximum privileges inside the container — and depending on the host configuration, that can escalate beyond it. Running as an unprivileged user is the principle of least privilege applied to the container.
FROM node:20-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
# Create an unprivileged user and transfer file ownership
RUN groupadd -r app && useradd -r -g app app \
&& chown -R app:app /app
USER app
CMD ["node", "dist/server.js"]
Official images like node already ship a ready-made node user — in that case USER node is enough. The critical point is making sure the user has read permission on the app files (and write only where strictly needed, like upload or cache directories).
Optimize layers and cache
Every RUN, COPY, and ADD instruction creates a layer. Order matters because Docker reuses the cache while nothing has changed — so put what changes least at the top. The classic mistake is copying the entire source before installing dependencies: any one-line change invalidates the cache and forces a fresh npm ci.
# Wrong: change 1 line of code → reinstall everything
COPY . .
RUN npm ci
# Right: dependencies reinstall only if package.json changed
COPY package*.json ./
RUN npm ci
COPY . .
Also chain related commands into a single RUN and clean the cache in the same layer. Cleaning in a later layer doesn't help — the layer that added the files is still there, adding to the final size.
# The cleanup must be in the SAME RUN as the install
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Use .dockerignore and pin versions
The .dockerignore works like .gitignore: it stops files from entering the build context sent to the daemon. Without it, a COPY . . can drag in your local node_modules, the entire .git folder, .env files with secrets, and logs. That bloats the image, slows the build, and — in the case of .env — is a direct security leak.
node_modules
.git
.env
*.log
dist
coverage
Dockerfile
.dockerignore
On versions: avoid FROM node:latest. The latest tag is a moving target — today's build can differ from tomorrow's, breaking reproducibility. Pin the version (node:20.11-slim) or, at the security extreme, pin the image's SHA256 digest to guarantee the exact bytes.
Frequently asked questions
How do I reduce the size of a Docker image?
The three highest-impact levers are: swapping the base for a -slim or -alpine variant, adopting multi-stage builds to discard build tools, and cleaning package caches in the same layer where they were created. A solid .dockerignore and dropping devDependencies in production round out the package. Together, these measures typically take an image from 1 GB down to the 100-200 MB range.
What is a multi-stage build and when should I use it?
It's a Dockerfile with more than one FROM, where earlier stages serve only to compile/prepare artifacts and the final stage copies just the result. Use it whenever there's a build step (TypeScript transpilation, compiling Go/Rust/Java, front-end bundling). For a simple interpreted app with no build step, the gain is smaller, but it still helps separate dev dependencies from production ones.
Why shouldn't I run the container as root?
Because it violates the principle of least privilege. If the application is compromised, a root process inside the container has far more power to cause damage and to try escaping to the host. Creating a dedicated user with USER is a cheap line of defense that significantly reduces the blast radius of an intrusion.
Is Alpine always the best choice for small images?
No. Alpine is the smallest, but it uses musl libc instead of glibc, which can break native dependencies and require recompilation, plus it has subtly different behaviors (DNS, timezones). For many cases -slim delivers 80% of the size benefit with 100% of the compatibility. Measure before adopting Alpine as your default.
What to take from this guide
A small image and a secure image are the same goal: get everything that doesn't run in production out of the final image. Start with the right base (-slim covers most cases), separate build from runtime with multi-stage to leave tools and source code behind, create a non-root user, and order your layers so the cache works in your favor. None of these changes require touching the application logic — they're Dockerfile adjustments that pay dividends on every docker pull, every deploy, and every security report. Apply one at a time, measure the result with docker images, and watch the number drop.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.