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
sudoprivileges. - Docker Engine 24 or later with BuildKit (the default builder), and your user in the
dockergroup. - 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.modand downloading modules before copying the source lets Docker cache dependencies between builds. CGO_ENABLED=0produces a statically linked binary, which is required because thestaticimage has no C library.-trimpathand-ldflags="-s -w"remove local paths and debug symbols from the binary.- Only
/out/serverreaches the final image; the compiler and source stay in the discardedbuildstage. USER nonroot:nonrootmakes 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-onlymounts the root filesystem read-only, so an attacker cannot drop tools or modify the binary.--tmpfs /tmpprovides a small in-memory writable directory for applications that need scratch space, without execute permission.--cap-drop ALLremoves every Linux capability; this service needs none. Add back only what an application requires, for example--cap-add NET_BIND_SERVICE.no-new-privilegesprevents privilege escalation through setuid binaries.--memoryand--pids-limitstop 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.
