Building clean Docker images for sysadmins
A guide on writing efficient, small, and secure Dockerfiles with multi-stage builds and non-root users.
Introduction
As system administrators, we are often tasked with deploying applications inside Docker containers. While it’s easy to just copy-paste a basic Dockerfile from a tutorial, production images require more care. We need them to be small for fast pull times, secure against privilege escalations, and clean of build dependencies.
In this guide, we’ll explore two crucial concepts for creating production-ready Docker images: multi-stage builds and running as non-root users.
1. Multi-Stage Builds
One of the most common mistakes is leaving compiler toolchains and build dependencies inside the final production container. This increases the attack surface and image size.
Multi-stage builds allow you to use a heavy build environment to compile or build your application, then copy only the compiled binaries or assets into a lightweight runtime image.
Here is an example for a Go application:
# Stage 1: Build environment
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
# Stage 2: Final runtime environment
FROM alpine:3.20
WORKDIR /app
# Copy the compiled binary from the builder stage
COPY --from=builder /src/app .
CMD ["./app"]
In this setup, Go runtime and tools (hundreds of megabytes) are discarded, leaving only the tiny Alpine base and the application binary.
2. Non-Root User Configuration
By default, Docker containers run commands as the root user. If a vulnerability is found in your application, an attacker could potentially gain root access to the host machine.
Always define a non-root user and switch to them before starting the application:
FROM alpine:3.20
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
CMD ["whoami"]
Summary
By applying these practices:
- You keep image sizes to a minimum.
- You restrict container privileges to enhance security.
- You separate build-time logic from runtime code.