Report this

What is the reason for this report?

Build Distroless Containers with BuildKit on Kubernetes

Published on February 18, 2026
Anish Singh Walia

By Anish Singh Walia

Sr Technical Writer

Build Distroless Containers with BuildKit on Kubernetes

Introduction

Production container images should include only the application binary and its runtime dependencies. Nothing more. A standard Debian or Ubuntu base image ships with a package manager, a shell, system utilities, and hundreds of libraries that your application never calls. Every one of those extra binaries increases image size, expands the container attack surface, and raises the number of CVEs that scanners flag on every build.

Distroless container images solve this problem by stripping out everything except the application and its required runtime. Today, the Kubernetes project itself runs on distroless base images. Paired with BuildKit, Docker’s modern build engine, and multi-stage builds, you can produce minimal, hardened images without changing your development workflow. DigitalOcean Kubernetes supports this feature on all currently supported cluster versions.

In this tutorial, you will take a sample Go application, convert its Dockerfile from a standard base image to a distroless image using BuildKit and multi-stage builds, push the result to DigitalOcean Container Registry (DOCR), and deploy it on DigitalOcean Kubernetes (DOKS). Along the way, you will compare image sizes, scan for vulnerabilities, and learn how to debug distroless containers in production using Kubernetes ephemeral containers.

Key Takeaways

  • Distroless images cut image size by 90% or more compared to standard Debian or Ubuntu base images, reducing pull times and storage costs.
  • Fewer packages means fewer CVEs. Removing shells, package managers, and unused libraries dramatically lowers the number of vulnerabilities that scanners detect.
  • BuildKit parallelizes multi-stage builds, making the compile-then-copy-to-distroless pattern fast and cache-friendly.
  • Debugging without a shell is possible. Kubernetes ephemeral containers let you attach a debug sidecar to any running pod, even one built from a distroless image.
  • The workflow is compatible with any CI/CD pipeline and integrates directly with DigitalOcean Container Registry and DigitalOcean Kubernetes.

Why Use Distroless Images in Production

Standard base images like debian:bookworm or ubuntu:24.04 include hundreds of packages your application does not use at runtime. These packages introduce three problems:

  1. Larger image size. A standard Debian image starts at roughly 124 MB before you add any application code. A distroless static image starts at about 2 MB.
  2. Wider attack surface. Each installed binary is a potential entry point for an attacker. Shells like bash and sh are commonly used in container breakout exploits. Distroless images do not include a shell.
  3. More CVE noise. Vulnerability scanners flag every known issue in every installed package. With fewer packages, scanner results focus on what actually matters to your application.

The following table compares common base image options:

Base Image Approximate Size Includes Shell Typical CVE Count
ubuntu:24.04 ~78 MB Yes 30+
debian:bookworm-slim ~74 MB Yes 25+
alpine:3.20 ~7 MB Yes (BusyBox) 5-10
gcr.io/distroless/static-debian12 ~2 MB No 0-2
gcr.io/distroless/base-debian12 ~20 MB No 0-5

Note: CVE counts change frequently as new vulnerabilities are discovered and patched. The numbers above reflect general trends observed in production environments and may differ when you run your own scans.

For backend and platform engineers working to harden their CI/CD pipelines, distroless images offer one of the simplest ways to reduce risk without rewriting application code.

Prerequisites

Before you begin this tutorial, you will need:

Step 1: Scaffold the Application and Baseline Dockerfile

Start by creating a simple Go HTTP server. Go is a good choice for demonstrating distroless builds because Go binaries are statically compiled by default, meaning the final binary has no external runtime dependencies.

Create a new project directory and add the application source:

mkdir distroless-demo && cd distroless-demo

Create a file named main.go with the following content:

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        hostname, _ := os.Hostname()
        fmt.Fprintf(w, "Hello from distroless!\nHostname: %s\n", hostname)
    })

    log.Printf("Server starting on port %s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

Next, initialize the Go module. This creates the go.mod file that the Dockerfile needs in order to copy and build your application:

go mod init go-server
go mod init distroless-demo

Now create a standard Dockerfile that uses a full Debian base image. This will serve as the baseline for comparison later:

touch Dockerfile

Now copy the below content into the Dockerfile:

FROM golang:1.23-bookworm

WORKDIR /app
COPY go.mod ./
COPY main.go ./

RUN go build -o server .

EXPOSE 8080
CMD ["./server"]

Build the baseline image with BuildKit (enabled by default in Docker 23.0+):

docker build -t distroless-demo:baseline .
Output
Sending build context to Docker daemon 4.096kB Step 1/7 : FROM golang:1.23-bookworm ---> 16c42bcc0084 Step 2/7 : WORKDIR /app ---> Using cache ---> d9381b6e4dfa Step 3/7 : COPY go.mod ./ ---> f37addb2acf1 Step 4/7 : COPY main.go ./ ---> 279e1f87cbec Step 5/7 : RUN go build -o server . ---> Running in 64879e86d557 ---> Removed intermediate container 64879e86d557 ---> 55b62c6b5d24 Step 6/7 : EXPOSE 8080 ---> Running in 0b7abc973f97 ---> Removed intermediate container 0b7abc973f97 ---> 8108c090cd12 Step 7/7 : CMD ["./server"] ---> Running in fc786afae645 ---> Removed intermediate container fc786afae645 ---> 42288f4e0180 Successfully built 42288f4e0180 Successfully tagged distroless-demo:baseline

Check the image size:

docker images distroless-demo:baseline

You will see output similar to:

REPOSITORY        TAG        IMAGE ID       CREATED         SIZE
distroless-demo   baseline   42288f4e0180   3 minutes ago   919MB

This baseline image is over 800 MB because it includes the entire Go toolchain, a Debian operating system, and all associated libraries. None of these are needed at runtime for a compiled Go binary.

Step 2: Enable and Verify BuildKit

BuildKit is Docker’s modern build engine. It replaces the legacy builder and provides several features that make multi-stage distroless builds faster:

  • Parallel stage execution. BuildKit runs independent build stages concurrently instead of sequentially.
  • Improved caching. BuildKit tracks content checksums rather than relying on heuristics, so cache invalidation is more precise.
  • Skipping unused stages. If a stage is not referenced in the final output, BuildKit does not execute it at all.

If you are running Docker Engine 23.0 or later, BuildKit is already the default. You can confirm this:

docker buildx version

You should see output showing the BuildKit version, such as:

github.com/docker/buildx v0.31.1 a2675950d46b2cb171b23c2015ca44fb88607531

Note: If you get the error docker: unknown command: docker buildx, the buildx plugin is not installed. This is common on Ubuntu/Debian where Docker is installed via apt rather than Docker Desktop.

Option 1 — If Docker was installed from Docker’s official APT repository, install the plugin with:

sudo apt-get update
sudo apt-get install -y docker-buildx-plugin

Option 2 — If Docker was installed from Ubuntu’s default packages (e.g., docker.io), or if Option 1 gives you Unable to locate package, download the plugin binary directly:

BUILDX_VERSION=$(curl -s https://api.github.com/repos/docker/buildx/releases/latest | grep -oP '"tag_name": "\K[^"]+')
curl -Lo docker-buildx "https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-amd64"
chmod +x docker-buildx
mkdir -p ~/.docker/cli-plugins
mv docker-buildx ~/.docker/cli-plugins/

Then verify by running docker buildx version again.

Step 3: Refactor to a Multi-Stage Distroless Build

This is the core step of the tutorial. You will split the Dockerfile into two stages:

  1. Build stage: Uses the full golang image to compile the application.
  2. Runtime stage: Uses a distroless base image to run only the compiled binary.

Create a new file named Dockerfile.distroless and copy the below content into it:

# Stage 1: Build
FROM golang:1.23-bookworm AS builder

WORKDIR /app
COPY go.mod ./
COPY main.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /app/server /server

EXPOSE 8080
USER 65532:65532
CMD ["/server"]

A few important details about this Dockerfile:

  • CGO_ENABLED=0 ensures the Go binary is fully statically linked, with no dependency on C libraries. This is required for the static-debian12 distroless base, which does not include glibc.
  • gcr.io/distroless/static-debian12:nonroot is the smallest distroless image (~2 MB). It is suitable for statically compiled binaries. If your application needs glibc (for example, a Python or Java app), use gcr.io/distroless/base-debian12 instead.
  • USER 65532:65532 runs the process as the nonroot user (UID 65532) defined in the distroless image. Using the numeric UID instead of the name nonroot is required when Kubernetes runAsNonRoot is enabled, because Kubernetes cannot verify non-root status from a string username.
  • The CMD must be in exec form (JSON array). Distroless images do not include a shell, so the shell form (CMD /server) will not work.

Build the distroless image:

docker build -f Dockerfile.distroless -t distroless-demo:distroless .

Check the image size:

docker images distroless-demo

You should see output similar to:

REPOSITORY        TAG          IMAGE ID       CREATED          SIZE
distroless-demo   distroless   8a1a3a3f24ce   52 seconds ago   9.54MB
distroless-demo   baseline     42288f4e0180   13 minutes ago   919MB

The distroless image is roughly 95 times smaller than the baseline. The exact sizes may vary depending on your application, but the reduction is consistently dramatic.

Test the distroless image locally to make sure it works:

docker run -p 8080:8080 distroless-demo:distroless

In another terminal, verify the response:

curl http://localhost:8080

You should see:

Hello from distroless!
Hostname: edb720cb0637

Step 4: Compare Image Size and Scan Results

Comparing image size is straightforward. Let’s also scan both images for vulnerabilities to quantify the security improvement.

If you have Docker Scout available (included with Docker Desktop), you can run:

docker scout cves distroless-demo:baseline
docker scout cves distroless-demo:distroless

Note: If you get the error docker: unknown command: docker scout, the Scout plugin is not installed. Docker Scout is bundled with Docker Desktop but is not included with Docker Engine on Linux servers. You can install it manually:

curl -fsSL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh -o install-scout.sh
sh install-scout.sh

This installs the Scout CLI plugin into ~/.docker/cli-plugins/. After installation, you will need to authenticate with a Docker Hub account:

docker login

Alternatively, you can skip Docker Scout entirely and use Trivy instead, which is a free open-source scanner that does not require authentication.

If you prefer an open-source alternative that works without Docker Desktop or a Docker Hub account, you can use Trivy:

sudo apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy

Then scan both images:

trivy image distroless-demo:baseline
trivy image distroless-demo:distroless

In this tutorial, we used Trivy to scan the images. You should see output similar to:

Output
Report Summary ┌──────────────────────────────────────────────┬──────────┬─────────────────┬─────────┐ │ Target │ Type │ Vulnerabilities │ Secrets │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ distroless-demo:baseline (debian 12.11) │ debian │ 5 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ app/server │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/bin/go │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/bin/gofmt │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/addr2line │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/asm │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/buildid │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/cgo │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/compile │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/covdata │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/cover │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/doc │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/fix │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/link │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/nm │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/objdump │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/pack │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/pprof │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/preprofile │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/test2json │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/trace │ gobinary │ 16 │ - │ ├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤ │ usr/local/go/pkg/tool/linux_amd64/vet │ gobinary │ 16 │ - │ └──────────────────────────────────────────────┴──────────┴─────────────────┴─────────┘

A typical comparison looks like this:

Metric Baseline (golang:1.23-bookworm) Distroless (static-debian12)
Image size ~919 MB ~9.54 MB
OS packages 300+ 0
Known CVEs (total) 50-100+ 0-2
High/Critical CVEs 5-15 0
Shell access Yes No

Note: Vulnerability counts depend on the specific point in time you run the scan and the current state of vulnerability databases. The key takeaway is that the distroless image consistently reports far fewer (often zero) CVEs because there are fewer packages to scan.

Step 5: Push to DigitalOcean Container Registry

Now that the image is built and tested locally, push it to the DigitalOcean Container Registry so your Kubernetes cluster can pull it.

First, create a container registry if you don’t have one yet. Replace <your-registry-name> with a unique name of your choice:

doctl registry create <your-registry-name>
Name              Endpoint                                    Region slug
anish-registry    registry.digitalocean.com/anish-registry    sfo2

Note: Each DigitalOcean account can have only one container registry. If you already have a registry, you can find its name with:

doctl registry get

If you get the error registry not configured for user when running doctl registry login, it means no registry exists on your account yet. Run the doctl registry create command above first.

Now log in to your registry:

doctl registry login
Logging Docker in to registry.digitalocean.com
Notice: Login valid for 30 days. Use the --expiry-seconds flag to set a shorter expiration or --never-expire for no expiration.

Note: If you get a permission denied error referencing /root/.docker/config.json, try the following fixes depending on how doctl was installed:

If doctl was installed via Snap (look for the warning Using the doctl Snap? in the output):

sudo snap connect doctl:dot-docker
doctl registry login

If doctl was installed via apt or a direct binary, the ~/.docker directory may be owned by root. Fix the ownership:

sudo chown -R $USER:$USER ~/.docker
doctl registry login

If you are running as the root user, ensure the directory exists with correct permissions:

mkdir -p /root/.docker
chmod 700 /root/.docker
doctl registry login

Tag the image with the full registry path:

docker tag distroless-demo:distroless registry.digitalocean.com/<your-registry-name>/distroless-demo:v1

Push the image:

docker push registry.digitalocean.com/<your-registry-name>/distroless-demo:v1
Output
a45f24bd9fc9: Pushed 33b37ab0b090: Pushed 6e7fbcf090d0: Pushed ad51d0769d16: Pushed 4cde6b0bb6f5: Pushed bd3cdfae1d3f: Pushed 6f1cdceb6a31: Pushed af5aa97ebe6c: Pushed 4d049f83d9cf: Pushed 114dde0fefeb: Pushed 4840c7c54023: Pushed 8fa10c0194df: Pushed a33ba213ad26: Pushed v1: digest: sha256:2ed0144daad224d8f93320dab9af466dddbf0385fb74625ee962b5692cd9d6db size: 3022

Pushing the image to the registry

Because the image is only about 9 MB, the push completes in seconds. Compare that to pushing a 919 MB baseline image over the same network. You can see that the push is much faster.

Step 6: Deploy to DigitalOcean Kubernetes

If you don’t already have a DigitalOcean Kubernetes cluster running, create one from the DigitalOcean Cloud Console:

  1. Log in to the DigitalOcean Cloud Console.
  2. Click Kubernetes in the left-hand navigation menu.
  3. Click Create Cluster.
  4. Choose a Kubernetes version: Select the latest recommended version (e.g., 1.31.x).
  5. Choose a datacenter region: Select the region closest to you or your users (e.g., SFO3, NYC1).
  6. Choose cluster capacity:
    • Under Node pool, select a node size. For this tutorial, Basic nodes with 2 vCPUs / 4 GB RAM (s-2vcpu-4gb) are sufficient.
    • Set the Node count to 2.
  7. Name your cluster: Give it a descriptive name such as distroless-demo-cluster.
  8. Click Create Cluster.

The cluster will take 4-5 minutes to provision. Once the status shows Running, connect your local kubectl by downloading the cluster configuration:

doctl kubernetes cluster kubeconfig save <your-cluster-name>
Output
Notice: Adding cluster credentials to kubeconfig file found in "/root/.kube/config" Notice: Setting current-context to <your-cluster-name>

Replace <your-cluster-name> with the name you chose (e.g., distroless-demo-cluster). You can also find this command on the cluster’s Getting Started tab in the Cloud Console.

Verify the connection:

kubectl get nodes

You should see your nodes in a Ready state:

Output
pool-fhhk2oyq7-khry0 Ready <none> 119s v1.34.1 pool-fhhk2oyq7-khryd Ready <none> 114s v1.34.1

Note: If kubectl get nodes shows a node with status NotReady or you see a control-plane node with taints, wait a few minutes for the cluster to fully initialize. On DigitalOcean Kubernetes (DOKS), the control plane is managed by DigitalOcean and does not appear in kubectl get nodes — you should only see your worker nodes.

Now connect your DOKS cluster to your container registry. If you haven’t already connected your DOKS cluster to your registry, run:

doctl registry kubernetes-manifest | kubectl apply -f -
Output
secret/registry-anish-registry created

This creates a Kubernetes secret containing your registry credentials. Next, patch the default service account to use this secret for pulling images:

kubectl patch serviceaccount default -p '{"imagePullSecrets": [{"name": "registry-<your-registry-name>"}]}'
Output
serviceaccount/default patched

Please replace <your-registry-name> with the name of your registry.

Now create a Kubernetes Deployment manifest. Save the following as deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: distroless-demo
  labels:
    app: distroless-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: distroless-demo
  template:
    metadata:
      labels:
        app: distroless-demo
    spec:
      containers:
        - name: distroless-demo
          image: registry.digitalocean.com/<your-registry-name>/distroless-demo:v1
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "50m"
              memory: "32Mi"
            limits:
              cpu: "200m"
              memory: "64Mi"
          securityContext:
            runAsNonRoot: true
            runAsUser: 65532
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false

Apply the deployment:

kubectl apply -f deployment.yaml
deployment.apps/distroless-demo created

Create a Service to expose the deployment:

apiVersion: v1
kind: Service
metadata:
  name: distroless-demo
spec:
  type: LoadBalancer
  selector:
    app: distroless-demo
  ports:
    - port: 80
      targetPort: 8080

Save this as service.yaml and apply it:

kubectl apply -f service.yaml
service/distroless-demo created

Wait for the external IP to be assigned. You can do this by running the following command and waiting for the EXTERNAL-IP to be assigned:

kubectl get svc distroless-demo --watch
NAME              TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
distroless-demo   LoadBalancer   10.110.72.38   <pending>     80:32426/TCP   24s

Once the EXTERNAL-IP is assigned, test the deployment:

curl http://<EXTERNAL-IP>

You should see the familiar response, with the hostname cycling between pods on each request:

Hello from distroless!
Hostname: distroless-demo-855f44756c-mhlc2

The architecture for this deployment follows a straightforward path: Build (locally or in CI) → Push to DOCR → Deploy on DOKS. This same pattern works with GitHub Actions, GitLab CI, or any CI/CD platform that supports Docker and kubectl.

Step 7: Debug Distroless Containers with Ephemeral Containers

One of the most common concerns about distroless images is that they lack a shell. Without bash or sh, you cannot kubectl exec into the container for troubleshooting. Kubernetes solves this with ephemeral containers, which let you attach a debug container to a running pod.

First, find the name of a running pod:

kubectl get pods -l app=distroless-demo

Then attach an ephemeral debug container using kubectl debug:

kubectl debug -it <pod-name> --image=busybox:latest --target=distroless-demo -- sh

This command:

  • Creates a temporary BusyBox container inside the same pod.
  • Shares the process namespace with the distroless-demo container, so you can inspect its processes.
  • Gives you a shell (sh) for interactive troubleshooting.

From inside the debug container, you can run commands like:

# List processes in the target container
ps aux

# Check network connectivity
wget -qO- http://localhost:8080

# Inspect the filesystem
ls /proc/1/root/

You should see output similar to:

Output
PID USER TIME COMMAND 1 65532 0:00 /server 20 root 0:00 sh 28 root 0:00 ps aux Hello from distroless! Hostname: distroless-demo-855f44756c-7r2xb

When you exit the shell, the ephemeral container is automatically removed. This approach gives you full debugging capabilities without compromising the security posture of your production image.

Note: Ephemeral containers require Kubernetes 1.25 or later. DigitalOcean Kubernetes supports this feature on all currently supported cluster versions.

Best Practices for Distroless Production Images

When adopting distroless images across your organization, keep these practices in mind:

  • Choose the right base image. Use static-debian12 for statically compiled binaries (Go, Rust). Use base-debian12 for applications that need glibc (Python, Java, Node.js). Use the language-specific variants like java21-debian12 or nodejs22-debian12 when available.
  • Always use the nonroot tag and numeric UIDs. Running containers as root, even inside a distroless image, weakens your security posture. The nonroot variants set the user to UID 65532. Always use USER 65532:65532 in your Dockerfile (not USER nonroot:nonroot) so that Kubernetes can verify non-root status when runAsNonRoot: true is set in the pod security context.
  • Pin image digests in production. Rather than relying on mutable tags like latest, pin to a specific digest (for example, gcr.io/distroless/static-debian12@sha256:abc123...) to ensure reproducible builds.
  • Scan images in CI before pushing. Integrate Trivy, Docker Scout, or another scanner into your CI pipeline so that images with high-severity CVEs never reach your registry.
  • Use readOnlyRootFilesystem: true in your Kubernetes security context. Since distroless images contain no writable system files, this setting adds defense in depth without affecting your application.

Frequently Asked Questions

1. What is a distroless container image?

A distroless container image is a Docker image that contains only the application binary and its runtime dependencies. It does not include a package manager, a shell, or standard Linux utilities. Google’s distroless project maintains the most widely used set of distroless base images, built from Debian packages but stripped to the absolute minimum.

2. Why are distroless images more secure than standard base images?

Distroless images remove the tools that attackers typically use after gaining initial access to a container. Without a shell, package manager, or network utilities like curl and wget, an attacker who compromises the application process has far fewer options for lateral movement or privilege escalation. This reduction in the container attack surface is one of the primary reasons organizations adopt distroless images for production workloads.

3. What is the difference between distroless and Alpine images?

Alpine Linux images are small (~7 MB) and include a shell (BusyBox) along with the apk package manager. Distroless images are even smaller (as low as 2 MB) and include no shell and no package manager at all. Alpine is a good choice when you need a small image but still want interactive access for debugging. Distroless is the better choice when you want the smallest possible attack surface and are willing to use Kubernetes ephemeral containers or debug image tags for troubleshooting.

4. How do you debug a distroless container that has no shell?

You have two primary options. First, you can use Kubernetes ephemeral containers with kubectl debug to attach a temporary debug container (like BusyBox or Alpine) to the running pod. This gives you shell access to inspect processes, network, and files without modifying the production image. Second, Google’s distroless project publishes :debug tagged variants of each image that include a BusyBox shell. You can swap to the debug tag temporarily during incident response: for example, gcr.io/distroless/static-debian12:debug.

5. What is BuildKit and why does it matter for multi-stage builds?

BuildKit is the modern build engine for Docker, enabled by default since Docker Engine 23.0. For multi-stage builds, BuildKit provides significant advantages: it runs independent stages in parallel, skips stages that the final image does not reference, and uses content-based caching for more precise cache invalidation. These features make the build-compile-copy pattern used in distroless Dockerfiles both fast and efficient.

Conclusion

You have now converted a standard container image into a hardened distroless image using BuildKit and multi-stage builds. The production image dropped from over 919 MB to under 10 MB, and the number of flagged CVEs dropped to near zero. You pushed the image to DigitalOcean Container Registry and deployed it to DigitalOcean Kubernetes with resource limits and security context policies in place. You also learned how to debug distroless containers using Kubernetes ephemeral containers, which removes the last practical barrier to running shell-free images in production.

Distroless images are not the right fit for every use case. Development and local debugging environments still benefit from full base images with interactive shells. But for production workloads where security, compliance, and image size matter, distroless combined with BuildKit and multi-stage builds is one of the most effective improvements you can make to your container pipeline.

Next Steps

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the author

Anish Singh Walia
Anish Singh Walia
Author
Sr Technical Writer
See author profile

I help Businesses scale with AI x SEO x (authentic) Content that revives traffic and keeps leads flowing | 3,000,000+ Average monthly readers on Medium | Sr Technical Writer @ DigitalOcean | Ex-Cloud Consultant @ AMEX | Ex-Site Reliability Engineer(DevOps)@Nutanix

Still looking for an answer?

Was this helpful?


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.