Docker from Basics to Production · Production-Ready Images

Small and Secure Images

Measure what a multi-stage build and hardening change: size, package count, user and writable paths.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Two images of the same Go service, a single-stage one and a multi-stage distroless one, compared on size, number of OS packages, user and whether a shell is available to an attacker.

The idea

Every package in an image is something that can have vulnerabilities, something scanners will flag, and something an attacker can use after a compromise. Reduce it:

  • Multi-stage builds keep compilers and build tools out of the runtime image.
  • Distroless or scratch images for static binaries contain no shell or package manager.
  • Non-root by default (distroless provides a nonroot user, UID 65532).
  • Read-only root filesystem at run time.
  • Rebuild regularly so base image security fixes reach you, and scan images in CI (Docker Scout, Trivy, Grype).

The code

Single-stage versus multi-stage distroless
trap 'docker image rm -f rc-fat:1 rc-slim:1 > /dev/null 2>&1' EXIT
cat > main.go <<'EOF'
package main

import (
	"fmt"
	"os"
)

func main() { fmt.Printf("hello, running as uid %d\n", os.Getuid()) }
EOF
printf 'module example.com/hello\n\ngo 1.26\n' > go.mod
cat > Dockerfile.single <<'EOF'
FROM golang:1.26-alpine
WORKDIR /src
COPY . .
RUN go build -o /app .
CMD ["/app"]
EOF
cat > Dockerfile.multi <<'EOF'
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app .

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]
EOF
docker build -q -f Dockerfile.single -t rc-fat:1 . > /dev/null
docker build -q -f Dockerfile.multi -t rc-slim:1 . > /dev/null
for img in rc-fat:1 rc-slim:1; do
  printf '%-10s size %-8s ' "$img" "$(docker image ls $img --format '{{.Size}}')"
  docker run --rm --read-only "$img" | tr -d '\n'
  printf ' | shell available: '
  docker run --rm --entrypoint sh "$img" -c 'echo yes' 2>/dev/null || echo no
done
echo "apk packages in the single-stage image: $(docker run --rm rc-fat:1 sh -c 'apk info 2>/dev/null | wc -l')"

Output

rc-fat:1   size 412MB    hello, running as uid 0 | shell available: yes
rc-slim:1  size 8.99MB   hello, running as uid 65532 | shell available: no
apk packages in the single-stage image: 17

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Shipping the build image ("it works") with compilers, source code and caches.
  • Using latest base images, so a rebuild silently changes the OS underneath you.
  • Disabling scanner findings instead of rebuilding on a patched base.
  • Assuming small means secure: an outdated small image is still vulnerable.

Key points

  • Multi-stage builds and minimal runtime bases cut size and attack surface.
  • Run as non-root with a read-only root filesystem.
  • Rebuild and scan images regularly.

Further reading: Multi-stage builds · Distroless images · Docker Scout

Practice questions

Why does removing the shell from a runtime image improve security?

Answer

An attacker who gets code execution has fewer tools to explore, download payloads or escalate; it also removes packages that could carry vulnerabilities.

How do base image security fixes reach your application image?

Answer

Only when you rebuild on the updated base and redeploy; schedule regular rebuilds and scanning rather than waiting for code changes.

What does -ldflags="-s -w" do in a Go build?

Answer

It strips the symbol table and DWARF debug information, making the binary smaller.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?