Safeguard
Containers

Dockerfile Best Practices: Security, Size, and Build Speed

Most Dockerfiles are copy-pasted from a tutorial and never revisited. Here's what actually shrinks image size, closes the common security holes, and speeds up rebuilds.

Safeguard Research Team
Research
5 min read

A production Dockerfile written once and never touched again is one of the most common sources of avoidable risk in a modern build pipeline. The short answer: good dockerfile best practices come down to four things — pin your base image, order layers so the cache actually works, copy only what you need with a proper .dockerignore, and run the final container as a non-root user. None of these require exotic tooling, and together they typically cut image size by half or more while removing a meaningful share of the CVEs a scanner would otherwise flag. This post walks through each practice with the reasoning behind it, not just the checklist.

Why does base image choice matter more than anything else in the Dockerfile?

The base image determines most of what a vulnerability scanner will later report, because every package it ships — used or not — becomes part of your attack surface. A full ubuntu:latest or node:latest image bundles a shell, package managers, compilers, and dozens of libraries your application never touches at runtime. Pinning to a specific, minimal tag (node:20-slim or node:20-alpine rather than node:latest) does two things at once: it makes builds reproducible, and it removes packages that only exist to make ad-hoc debugging easier. For a docker image node build specifically, the slim and alpine variants strip out build tools and documentation that a compiled or bundled app doesn't need in production, which is usually the single biggest size reduction available in the whole Dockerfile.

How should you order Dockerfile instructions for faster builds?

Order instructions from least-frequently-changed to most-frequently-changed so Docker's layer cache actually gets used. Dependency manifests (package.json, requirements.txt, go.mod) change rarely compared to application source code, so copying and installing dependencies before copying the rest of the source lets Docker reuse the dependency-install layer on every rebuild where only application code changed. A Dockerfile that does COPY . . before RUN npm install invalidates the cache on every single code change, forcing a full dependency reinstall each time — which on a large node_modules tree can add minutes to every CI run. Reordering those two lines alone is often the fastest build-speed win available, with zero downside.

What does a proper docker ignore file actually prevent?

A docker ignore file (.dockerignore) prevents the build context from including files that bloat the image or leak secrets into it. Without one, COPY . . happily copies .git directories, local .env files, test fixtures, and node_modules from the host into the build context — and anything copied into an image layer stays recoverable from that layer even if a later step deletes it. A minimal .dockerignore should exclude .git, node_modules, *.env, build artifacts, and CI configuration. This is one of the cheapest security controls available: it takes one file and a few lines, and it closes off an entire class of accidental secret-in-image incidents that show up regularly in container scan reports.

How do multi-stage builds reduce both size and risk?

Multi-stage builds let you compile or bundle an application in a full-featured "builder" stage, then copy only the finished artifact into a minimal final stage — so the compiler, package manager, and build-time dependencies never ship to production. A typical Go or Java build might need a 900 MB SDK image to compile, but the compiled binary or JAR itself is a few megabytes; a multi-stage Dockerfile captures that difference automatically. This matters for security as much as size, because a smaller final image has fewer packages for a scanner (or an attacker) to find anything in. Teams evaluating a static source code analysis or SCA tool often notice their scan results improve dramatically the moment they switch to multi-stage builds, simply because there's less surface area left to scan.

Should containers run as root by default?

No — containers should run as a non-root user unless there's a specific, documented reason not to. The default behavior of docker run is to execute as root inside the container unless the Dockerfile says otherwise, which means a container breakout or a vulnerable dependency with arbitrary code execution inherits root privileges inside the container namespace. Adding a dedicated USER directive (RUN adduser --system appuser followed by USER appuser) before the final CMD closes that gap for a few lines of Dockerfile. Combined with a read-only root filesystem where the application allows it, this single change removes an entire category of privilege-escalation findings that container security tooling — including Safeguard's SCA and SAST/DAST products — routinely flag in default configurations.

FAQ

What's the single highest-impact Dockerfile change to make first?

Switching from a latest or full OS base image to a pinned, minimal image (slim, alpine, or distroless) usually produces the largest immediate reduction in both image size and reported CVEs, with the least implementation effort.

Does .dockerignore affect anything besides image size?

Yes. Beyond trimming size, it prevents secrets, local config, and version control metadata from ever entering the build context, which removes a common source of accidental credential exposure in shipped images.

Is alpine always the right choice for a Node image?

Not always — alpine uses musl libc instead of glibc, which occasionally breaks native Node addons that expect glibc. slim variants (Debian-based, glibc, but stripped of build tools) are a safer default when you depend on native modules; alpine is better when image size is the priority and your dependencies are pure JavaScript.

How do these practices fit into a CI/CD pipeline?

Layer ordering and multi-stage builds speed up the build step itself, while non-root users and minimal base images reduce what a scanner in the pipeline needs to flag — pairing well with an SAST/DAST gate that runs on every merge rather than a one-off audit.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.