EN

How we are building images with zero CVEs #1: reducing the attack surface (Alpine and distroless)

This is the first post in a technical series about the engineering decisions behind Quor's images. Each article will explore a specific layer of our approach to delivering container images with near-zero CVEs. We start with the most fundamental: the choice of the base image.

Security Researcher

Heitor Gouvêa

When a team does docker pull node:lts and uses this image as a base, they silently inherit much more than the Node.js runtime. The node:22 image (alias of node:lts) is built on top of buildpack-deps, a general-purpose image that includes build tools for multiple language ecosystems. The result: 661 installed packages, including full Python, GCC, Make, and dozens of development libraries. None of which are necessary to run a Node.js application in production.

This is not a detail. It is the structural cause of most of the CVEs that show up in engineering team security scanners.

Attack surface in containers: what this means in practice

Attack surface is the set of entry points that an attacker can exploit to compromise a system. In containers, every component present in the image is a potential entry point:

  • Operating system packages with known vulnerabilities (CVEs);

  • Interactive shells (bash, sh) that facilitate the execution of arbitrary commands after an initial exploit;

  • Package managers (apt, apk, pip) that can be used to install additional tools inside the compromised container;

  • Network utilities (curl, wget, netcat) that facilitate data exfiltration or downloading payloads;

  • Compilers and build tools that allow compiling malicious code within the environment.

The logic is: every additional component is an additional vector. A container that runs only the application binary and the strictly necessary libraries has an attack surface orders of magnitude smaller than a container based on a full distribution.

Comparing variants of Node.js 22 images (Active LTS):

Image

Packages

Reported CVEs

Size

node:22 (fat)

~413

~36

~1.64GB

registry.quor.dev/default/node:20-alpine

~ 50

~0 reported

~54 MB

The difference between node:22 and a distroless image is not marginal. It is an order of magnitude in the number of packages and reported CVEs.

What is Alpine

Alpine Linux is a distribution built with a specific goal: to be small, simple, and secure. Unlike Debian and Ubuntu, which were designed for general use on servers and desktops, Alpine was born for embedded environments and containers.

Its technical pillars:

  • musl libc instead of glibc: alternative and leaner implementation of the standard C library;

  • BusyBox: replacement of hundreds of GNU utilities with a single modular binary;

  • apk: its own package manager, significantly simpler than apt/yum;

  • Minimal base: the official base image is under 8 MB and includes only the essentials for a functional shell.

Why Alpine structurally reduces CVEs

When an image is based on Debian, it inherits the Debian package repository. This repository is huge and maintained to cover an enormous variety of use cases. Many packages have open CVEs that simply take time to be patched during the distribution's release cycle. Alpine, being smaller and more focused, has fewer exposed packages. In addition, its musl/BusyBox base historically has fewer vulnerabilities than GNU equivalents. In practice, a node:22-alpine image starts from a base that typically has zero reported CVEs, compared to dozens or hundreds in the Debian variant.

Trade-offs you need to know

Alpine is not a universal solution. There are real trade-offs:

  1. musl vs glibc: Some applications behave differently when compiled against musl. In particular, applications that depend on specific glibc behaviors (locale handling, DNS resolver, behavior of certain system calls) can present inconsistencies. For most backend web applications, this is not a practical problem.

  2. Native Node.js modules: If your application depends on modules that need to be compiled from C++ (such as bcrypt, node-sqlite3, sharp), Alpine can complicate the build process. 

The standard solution is to use multi-stage builds: Alpine (or a more complete image) in the build stage, minimal Alpine in the runtime stage.

Official support: Node.js builds for Alpine still have experimental status upstream. For critical production use, this is a point to consider.

What is distroless

Distroless is a more radical concept than Alpine. Instead of starting from a lean Linux distribution, the distroless approach removes the very notion of a distribution from the final image. A distroless image contains:

  • The language runtime (Node.js, JVM, Python, Go binary);

  • Strictly necessary system libraries (libc, libssl, libgcc);

  • TLS certificates;

  • Basic timezone and locale.

A distroless image does not contain:

  • Shell (bash, sh, busybox);

  • Package manager (apt, apk, pip);

  • System utilities (curl, wget, ls, ps, top);

  • Any other tool that is not required to run the application.

Google Distroless (gcr.io/distroless) is the original project (some vendors distribute forks), maintained by Google. Images built with Bazel directly from Debian packages, without including the package system itself. It produces extremely small images without any shell.

Why distroless reduces the attack surface more than Alpine

The absence of a shell is the fundamental difference. In terms of security:

No shell = no post-exploitation interactive execution. Most attacks against containers assume that, after exploiting a vulnerability in the application (RCE, command injection), the attacker can execute commands on the underlying system. Without a shell available, this step fails. The attacker may have access to the application process, but cannot easily pivot to arbitrary command execution.

No package manager = no tool installation. Lateral movement and persistence techniques frequently involve installing tools inside the compromised container (curl to download a backdoor, apt install to install an exploit). Distroless eliminates this possibility.

Fewer packages = fewer CVEs structurally. With fewer than 10 total packages, there is simply less code with potential vulnerability.

Trade-offs of distroless

Debugging is harder. Without a shell, you cannot run $ kubectl exec -it and navigate the container. This requires a change in operational practice: using distroless/debug in development environments (which includes busybox), and having sufficient observability (structured logs, traces) to not need interactive access in production.

child_process.exec() does not work. In distroless Node.js, calls that depend on a shell (child_process.exec, child_process.spawn with shell: true) fail because there is no shell available. Applications that need to execute subprocesses must use child_process.spawn with the binary directly.

Installing dependencies requires a multi-stage build. There is no npm install inside a distroless image. Dependencies must be copied from a previous stage. This is an architectural requirement, not an operational limitation; multi-stage builds should be standard practice anyway.

How Quor uses both approaches

At Quor, the choice between Alpine and distroless depends on the image type and runtime requirements.

Alpine is used when:

  • The application needs some controlled interactivity with the system (execution of subprocesses via shell);

  • The runtime does not have a stable distroless variant available;

  • It is necessary to install additional system packages during the build;

  • The image is used in contexts where operational compatibility with debugging tools is an explicit requirement.

Distroless is used when:

  • The runtime has stable support (Node.js LTS, JVM, Python, Go binaries);

  • The application does not depend on shell execution at runtime;

  • The priority is maximum attack surface minimization;

  • The environment has sufficient observability to dispense with interactive access to the container.

Beyond the choice of base, all Quor images are built directly from projects' source code, instead of depending on precompiled distribution packages. This allows security patches to be applied ahead of time, without depending on the release cycle of Debian, Ubuntu, or Alpine, and limits vulnerabilities inherited from broader distribution chains. We will cover this topic in more detail in a future article.

Concrete technical benefits

The most direct consequence is the structural elimination of CVEs. An image with 8 packages has an attack surface orders of magnitude smaller than an image with 661 packages. Not because the CVEs were "resolved", but because most of the vulnerable components simply do not exist in the image.

Smaller images measurably impact the pipeline:

  • Shorter pull times during deploys and cold starts in Kubernetes;

  • Lower storage consumption in registries and cluster nodes;

  • Lower bandwidth during distribution in multi-region environments;

  • Faster container startup, especially relevant in environments with frequent horizontal scaling.

In environments where hundreds of pods are started daily, the difference between 1 GB and 160 MB per image has a real impact on cost and scaling latency.

Reduction of noise in security pipelines

A frequently underestimated effect: images with near-zero CVEs eliminate noise from security scanners integrated into CI/CD. Pipelines configured to block builds with high or critical severity CVEs stop being interrupted by vulnerabilities in packages that do not even exist in the application's runtime. Teams stop spending hours on manual triaging of irrelevant vulnerabilities. This directly impacts deploy frequency and lead time, two of the four DORA Metrics.

Post-exploitation security

Even if a vulnerability in the application is successfully exploited, the absence of a shell, package manager, and system tools in distroless images drastically limits what the attacker can do after the initial compromise. This does not eliminate the risk, but it significantly reduces the blast radius of an incident.

  1. https://github.com/docker-library/buildpack-deps

Operating Kubernetes in production for more than 13 years. With Quor, this experience extends to software supply chain security as well.