Building distroless container images

Page contents

Container workloads have sparked a new reaosn to optimize filesize, preferring smaller base images over full-blown distro variants - but skipping the base distro entirely is an option too.

Base image issues

In modern container environments, container size is becoming increasingly important. An image has to be pushed to a registry, stored and eventually downloaded by a deployment server or cluster. Smaller images make this process faster and cheaper, with real world consequences:

Suppose your application runs on a kubernetes cluster for high availability. A node fails, and kubernetes schedules migrates your application container to a new node. Before your application comes back online, the new node must first download the container image - the smaller it is, the faster your application recovers.


Container operators have long started optimizing their container images to be small for these reasons, preferring trimmed down base images like debian:bookworm-slim or alpine.


But even though slimmed down, these images still ship a lot of executables and libraries that your application may not even need, adding pointless image size and attack surface.

Building containers from scratch

A lesser known base image option is ... no image at all. A Dockerfile can specify scratch as a base image to start with an entirely empty image:

FROM scratch
COPY myapp /app
CMD ["/app/myapp"]

The resulting image contains nothing but the copied myapp executable. It is the smallest possible container you could produce. But no dependencies can be an unexpected pitfall even for compiled applications.


Suppose you write a simple hello world application in C:


main.c

#include <stdio.h>

int main(){
  puts("Hello world!");
}

Compile it normally:

gcc main.c -o myapp

and build the container again:

docker build -t myapp .

Running the container will throw a somewhat misleading error:

exec /app/myapp: no such file or directory

That doesn't mean it the executable wasn't copied, but rather that it's dependencies were not found. Most executables are linked dynamically, meaning they expect some shared libraries to be present on the host when running.


To fix it, simply compile it staticallyto bundle all dependencies directly into the executable:

gcc -static main.c -o myapp

Reusing the previous Dockerfile, the resulting executable is 744kB in size, producing a tiny final container image of only 756kB.


Remember that using no base image also means no common shared libraries, so compile applications statically for containers without base images.

The distroless image family

Now that we have seen how to produce container images without base images and how annoying dependencies can be, you might already see a problem: what if your application isn't written in a compilable language like javascript, python or php?


Turns out Google has hit this issue long before you, and published the distroless base image family on GitHub, with base images containing nothing but common language runtimes and their dependencies.


To build on our C example from earlier, we could use the gcr.io/distroless/cc-debian13 image as a base and not forego the static compilation. Even a dynamically linked executable would work fine in that setup - although it does increase the container size heavily from 758kB of the scratch image to 26.7MB.


There are also distroless images available for common programming languages like python3, Node.js or Java. These allow deploying applications in effectively ultra-slimmed debian 12 or 13 containers without having to worry about static compilation or runtime dependencies for the most part.


For example, let's create a Node.js sample app:


main.js

console.log("Hello world!");

adjust the Dockerfile:

FROM gcr.io/distroless/nodejs24-debian13
COPY main.js /app/main.js
CMD ["node", "/app/main.js"]

After building the container image is 150MB in size, instead of the 1.13GB of the node:24 base image.

Using multi-stage distroless builds

Since container images support builds involving ephemeral containers that aren't added to the final image size, you can combine the best of both worlds. For example, a Node.js application will likely require npm dependencies, but that is not available.

The common solution is to use an official base image like node:24 to fetch dependencies and prepare the source code, then copy runtime-ready files to the distroless image:

FROM node:24 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM gcr.io/distroless/nodejs24-debian13
COPY --from=builder /app/dist /app
CMD ["node", "/app/main.js"]

Now you have the convenience of complete Node.js tooling during the preparation step, but still end up with a minimal production image., since only the contents of /app/dist of the builder container are copied into the final image,

Drawbacks of distroless images

While advantages like faster container image delivery, reduced attack surface and lower storage costs are very appealing, there are more nuanced drawbacks to consider before jumping into distroless or scratch images.


Many applications have what you might call "hidden dependencies". They will run in a distroless container, but perhaps not the way you would expect. Most notorious here are problems like missing timezone data, no mime type information or missing SSL root certificates breaking all TLS connections.


Such problems can be hard to debug in production, as the runtime behavior between inside and outside the container varies for no obvious reason.


The security win also comes at a cost: If your application has any dependencies, you now need to keep track of new versions manually. No convenient package manager to fetch the latest release on image build, but instead manual library hunting or bespoke scripts that now need to be managed by someone.


Distroless base images strike a decent middle ground for these problems, but even they require more discipline and application understanding than simply using alpine as a base image and using the package manager to fetch whatever your app needs to run.


Whether distroless is useful to you really depends on your priorities - large scale deployments with many containers or high security constraints will benefit heavily, while smaller teams or those favoring faster shipping and developer convenience might want to stick with alpine.

More articles

Automating isolated systems over USB

Reliable scripted workflows without network access or APIs

Understanding OATH, OAuth and OIDC

A primer on modern SSO standards

Building a robust CI pipeline for bash

Turning tech debt into software projects