Table of Contents
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. 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 container registry (DOCR), and deploy it on 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 container registry and 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:
- 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.
- Wider attack surface. Each installed binary is a potential entry point for an attacker. Shells like
bashandshare commonly used in container breakout exploits. Distroless images do not include a shell. - 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:
- A local machine with Docker Engine 23.0+ installed. BuildKit is the default builder starting with Docker Engine 23.0. You can verify this by running
docker buildx version. - The
doctlcommand-line tool installed and authenticated with your cloud account. - The
kubectlcommand-line tool installed. - A Kubernetes cluster with at least one node running. If you need to create one, follow the DOKS quickstart guide.
- A container registry. If you need to create one, follow the DOCR setup guide.
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 .
[secondary_label 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:
- Build stage: Uses the full
golangimage to compile the application. - 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=0ensures the Go binary is fully statically linked, with no dependency on C libraries. This is required for thestatic-debian12distroless base, which does not include glibc.gcr.io/distroless/static-debian12:nonrootis 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), usegcr.io/distroless/base-debian12instead.USER 65532:65532runs the process as thenonrootuser (UID 65532) defined in the distroless image. Using the numeric UID instead of the namenonrootis required when KubernetesrunAsNonRootis enabled, because Kubernetes cannot verify non-root status from a string username.- The
CMDmust 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:
[secondary_label 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 container registry
Now that the image is built and tested locally, push it to the 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 www.progressiverobot.com/anish-registry sfo2
Note: Each cloud 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 www.progressiverobot.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 www.progressiverobot.com/<your-registry-name>/distroless-demo:v1
Push the image:
docker push www.progressiverobot.com/<your-registry-name>/distroless-demo:v1
[secondary_label 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
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 Kubernetes
If you don't already have a Kubernetes cluster running, create one from the the cloud provider Cloud Console:
- Log in to the the cloud provider Cloud Console.
- Click Kubernetes in the left-hand navigation menu.
- Click Create Cluster.
- Choose a Kubernetes version: Select the latest recommended version (e.g.,
1.31.x). - Choose a datacenter region: Select the region closest to you or your users (e.g.,
SFO3,NYC1). - 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.
- Name your cluster: Give it a descriptive name such as
distroless-demo-cluster. - 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>
[secondary_label 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:
[secondary_label 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 Kubernetes (DOKS), the control plane is managed by the cloud provider 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 -
[secondary_label 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>"}]}'
[secondary_label 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: www.progressiverobot.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-democontainer, 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:
[secondary_label 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. 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-debian12for statically compiled binaries (Go, Rust). Usebase-debian12for applications that need glibc (Python, Java, Node.js). Use the language-specific variants likejava21-debian12ornodejs22-debian12when available. - Always use the
nonroottag and numeric UIDs. Running containers as root, even inside a distroless image, weakens your security posture. Thenonrootvariants set the user to UID 65532. Always useUSER 65532:65532in your Dockerfile (notUSER nonroot:nonroot) so that Kubernetes can verify non-root status whenrunAsNonRoot: trueis 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: truein 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
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.
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.
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.
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.
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 container registry and deployed it to 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
- Learn more about Kubernetes: Read the DOKS documentation to explore features like auto-scaling, monitoring, and cluster management.
- Optimize Docker images further: Follow the How To Optimize Docker Images for Production tutorial for additional strategies including Alpine-based builds.
- Set up CI/CD for automated deployments: Use the the cloud provider GitHub Actions integration to automatically build distroless images and deploy them to your cluster on every push.
- Explore container registry management: Follow the How to Set up container registry guide to configure registry integration with your DOKS cluster.
- Get started with Kubernetes and kubectl: Reference the kubectl Cheat Sheet for common cluster management commands.
- Try Kubernetes: Sign up for the cloud provider and get $200 in free credits for 60 days to deploy your own distroless containers on a managed Kubernetes cluster.