A container image built with default habits usually ships a full operating system, a compiler, a package manager and a root user, none of which the application needs at run time. Every extra package is a potential CVE and every extra tool is something an attacker can use after a breach. In this tutorial you will take a small Go web service from a naive single-stage image to a hardened one: a multi-stage build on a distroless base, a non-root user, no secrets in the layers, a vulnerability scan, and a runtime configuration with a read-only filesystem and no Linux capabilities. The steps run on Ubuntu 24.04, but the Dockerfiles work on any Docker host.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Docker Engine 24 or later with BuildKit (the default builder), and your user in the docker group.
  • Trivy installed, to compare vulnerability counts in Step 5.
  • Basic familiarity with writing a Dockerfile.

You do not need Go installed on the host: the application is compiled inside a build container.

Step 1 - Creating the sample application

Create a project directory with a minimal HTTP service:

mkdir -p ~/hello-hardened && cd ~/hello-hardened
nano main.go
package main

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

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Create the module file:

nano go.mod
module example.com/hello

go 1.25

Step 2 - Building a naive image as a baseline

To measure the improvement, first build the image the way many projects do: one stage, based on the full Go image, running as root.

nano Dockerfile.naive
FROM golang:1.25
WORKDIR /app
COPY . .
RUN go build -o server .
EXPOSE 8080
CMD ["./server"]

Build it:

docker build -f Dockerfile.naive -t hello:naive .

This image contains Debian, the Go toolchain, Git, GCC and your source code, and its process runs as root. Keep it for the comparison in Step 5.

Step 3 - Using a multi-stage build and a distroless base

A multi-stage build compiles the application in one image and copies only the result into a second, minimal image. The final stage here uses Google's distroless static image, which contains CA certificates, time zone data and /etc/passwd, but no shell and no package manager. The :nonroot tag sets the default user to nonroot (UID 65532).

Before writing the Dockerfile, create a .dockerignore file so that local files such as Git history, editor files or .env files never enter the build context, and therefore never end up in an image by accident:

nano .dockerignore
.git
.env
*.env
*.log
Dockerfile*
.dockerignore

Now create the hardened Dockerfile:

nano Dockerfile
# syntax=docker/dockerfile:1

FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

What each choice does:

  • Copying go.mod and downloading modules before copying the source lets Docker cache dependencies between builds.
  • CGO_ENABLED=0 produces a statically linked binary, which is required because the static image has no C library.
  • -trimpath and -ldflags="-s -w" remove local paths and debug symbols from the binary.
  • Only /out/server reaches the final image; the compiler and source stay in the discarded build stage.
  • USER nonroot:nonroot makes the non-root user explicit, so it is visible in reviews and scanners.

Build the image:

docker build -t hello:hardened .

Compare the sizes of both images:

docker images hello
REPOSITORY   TAG        IMAGE ID       CREATED          SIZE
hello        hardened   3f0a1c9e2b7d   10 seconds ago   9.8MB
hello        naive      8d2e4b61c0af   2 minutes ago    930MB

For interpreted languages the same pattern applies: install dependencies in a build stage, then copy only the application and its runtime dependencies into a slim final image such as python:3.13-slim or node:22-alpine, and add a non-root user there.

Step 4 - Confirming the image runs as a non-root user

Check the user configured in each image:

docker inspect --format '{{.Config.User}}' hello:naive
docker inspect --format '{{.Config.User}}' hello:hardened

nonroot:nonroot

The empty first line means the naive image runs as root. If you use a base image without a ready-made non-root user, create one in the Dockerfile and switch to it before CMD. On Alpine:

RUN addgroup -S app && adduser -S -G app -u 10001 app
USER app

Files copied into the image stay owned by root and read-only for this user, which is what you want: the application can read its code but not modify it.

Step 5 - Scanning both images for vulnerabilities

Compare the vulnerability counts with Trivy:

trivy image --quiet --severity HIGH,CRITICAL hello:naive | grep Total
trivy image --quiet --severity HIGH,CRITICAL hello:hardened | grep Total

The naive image reports vulnerabilities in dozens of Debian packages it never uses at run time, while the distroless image typically reports zero or close to zero, limited to the few packages it contains and the Go standard library compiled into your binary. The exact numbers change daily as new CVEs are published.

Also check the Dockerfile itself for misconfigurations such as a missing USER:

trivy config --quiet .

Rebuild regularly, even when your code does not change, so the image picks up fixed base layers. To make builds reproducible, pin the base image to a digest instead of a tag. Find the current digest:

docker buildx imagetools inspect gcr.io/distroless/static-debian12:nonroot | grep Digest

Then use it in the FROM line, for example FROM gcr.io/distroless/static-debian12:nonroot@sha256:your_digest, and let a tool such as Renovate or Dependabot propose updates.

Step 6 - Keeping secrets out of image layers

Anything written during a build, including values passed with --build-arg or ENV, is stored in the image layers and history, where anyone who can pull the image can read it. Deleting the file in a later RUN does not help, because the earlier layer still contains it.

When a build step needs a credential, for example a token for a private package registry, mount it with a BuildKit secret instead. The secret is available only while that single RUN instruction executes. Try it in a separate directory:

mkdir -p ~/secret-demo && cd ~/secret-demo
printf 'example-token' > api_token.txt
nano Dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN --mount=type=secret,id=api_token \
    test -s /run/secrets/api_token && echo "token available during this step only"

Build it, passing the secret from the file:

docker build --progress=plain --secret id=api_token,src=api_token.txt -t secret-demo .

The build log prints token available during this step only. Now confirm that the secret is not in the final image or its history:

docker run --rm secret-demo ls /run/secrets
docker history --no-trunc secret-demo | grep -c example-token
ls: /run/secrets: No such file or directory
0

Runtime secrets, such as database passwords, should be injected when the container starts: through Docker or Kubernetes secrets mounted as files, or environment variables set by your orchestrator, never baked into the image. Remove the demo files when you are done:

cd ~/hello-hardened && rm -rf ~/secret-demo

Step 7 - Running the container with a locked-down runtime

A hardened image is only half of the job; the runtime flags limit what a compromised process can do. Start the service with:

docker run -d --name hello \
  -p 127.0.0.1:8080:8080 \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL \
  --security-opt no-new-privileges=true \
  --memory 128m --pids-limit 100 \
  hello:hardened
  • --read-only mounts the root filesystem read-only, so an attacker cannot drop tools or modify the binary.
  • --tmpfs /tmp provides a small in-memory writable directory for applications that need scratch space, without execute permission.
  • --cap-drop ALL removes every Linux capability; this service needs none. Add back only what an application requires, for example --cap-add NET_BIND_SERVICE.
  • no-new-privileges prevents privilege escalation through setuid binaries.
  • --memory and --pids-limit stop a runaway or compromised process from exhausting the host.

Docker's default seccomp profile stays active, which blocks dangerous system calls. Check that the service answers:

curl http://127.0.0.1:8080/
ok

Confirm the runtime settings and the user the process runs as on the host:

docker inspect --format 'readonly={{.HostConfig.ReadonlyRootfs}} capdrop={{.HostConfig.CapDrop}}' hello
ps -o user,pid,cmd -C server
readonly=true capdrop=[ALL]
USER         PID CMD
65532      14872 /server

The process runs as UID 65532, not root, both inside the container and on the host. In Docker Compose, the same settings are read_only: true, tmpfs, cap_drop: [ALL] and security_opt: [no-new-privileges:true].

Step 8 - Applying the same settings in Kubernetes

In Kubernetes, express these restrictions in the pod's securityContext. The following Deployment runs the hardened image with the same guarantees:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 2
  selector:
    matchLabels:
      app: hello
  template:
    metadata:
      labels:
        app: hello
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: hello
          image: registry.example.com/hello:1.0.0
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            limits:
              memory: 128Mi
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 16Mi

runAsNonRoot: true makes the kubelet refuse to start the container if the image would run as root, which protects you if someone later changes the base image. These settings satisfy the Kubernetes restricted Pod Security Standard, which you can enforce per namespace with the label pod-security.kubernetes.io/enforce=restricted.

Troubleshooting

The container exits with exec /server: no such file or directory. The binary is dynamically linked and the static base image has no C library. Build with CGO_ENABLED=0, or use gcr.io/distroless/base-debian12:nonroot if you need cgo.

You need a shell to debug a distroless container. Distroless images have none by design. Use docker debug hello if your Docker edition provides it, or attach a temporary debugging container that shares the process namespace: docker run --rm -it --pid container:hello --network container:hello busybox sh. In Kubernetes, use kubectl debug with an ephemeral container.

The application fails with read-only file system. Find the path it writes to in the error message and mount a tmpfs (Docker) or emptyDir (Kubernetes) volume only at that path. Do not drop --read-only to fix it.

permission denied when the application reads a file copied into the image. The file lacks read permission for other users. Fix the mode in the build stage, or use COPY --chown=nonroot:nonroot only for files the application must own.

Conclusion

You reduced a 930 MB root-owned image to a roughly 10 MB static binary on a distroless base that runs as UID 65532, kept build secrets out of the layers, verified the result with Trivy, and ran it with a read-only filesystem, no capabilities and resource limits. The biggest gains come from the multi-stage build and the minimal base, and the runtime flags contain the damage if something is still exploited. As next steps, add trivy image --exit-code 1 to your CI pipeline, sign your images with Cosign, and enforce non-root and read-only settings cluster-wide with an admission controller such as OPA Gatekeeper.