All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

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.

How to Build a Smaller, More Secure Dockerfile
COVER · Tutorials

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.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile