Smaller Docker images are pulled and deployed faster, use less disk and registry space, and contain fewer packages that can carry vulnerabilities. Faster builds come from the same discipline: a Dockerfile that orders its layers well rebuilds in seconds instead of minutes. In this tutorial you will take a Python application from a naive Dockerfile to an optimized one step by step, measuring the effect of each change, and then build a Go service into an image of a few megabytes with a multi-stage build.
Prerequisites
To follow this tutorial you need:
- A server or workstation running Ubuntu 24.04 LTS, for example a CubePath VPS.
- A non-root user with
sudoprivileges, added to thedockergroup. - Docker Engine and the Buildx plugin installed from Docker's official repository. Buildx makes BuildKit the default builder, which Step 5 relies on.
- About 3 GB of free disk space for the intermediate images.
Step 1 - Measuring a naive baseline
Create a project with a small Flask application:
mkdir ~/imgdemo
cd ~/imgdemo
nano app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello from an optimized image\n"
nano requirements.txt
flask==3.1.0
gunicorn==23.0.0
Now write the kind of Dockerfile that is common in first attempts:
nano Dockerfile.naive
FROM python:3.12
COPY . /app
WORKDIR /app
RUN apt-get update
RUN apt-get install -y curl vim
RUN pip install -r requirements.txt
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Build it and look at the size:
docker build -f Dockerfile.naive -t imgdemo:naive .
docker image ls imgdemo
REPOSITORY TAG IMAGE ID CREATED SIZE
imgdemo naive 8f3a1c2b9d4e 10 seconds ago 1.1GB
Exact sizes vary with the base image release, but expect more than 1 GB. This Dockerfile has four problems: a full Debian base with compilers and headers the app never uses, tools installed "just in case", package caches left inside the image, and COPY . /app before installing dependencies, which invalidates the dependency layer on every code change.
Step 2 - Choosing a smaller base image
The base image is usually the largest part of the final image. For Python, the official tags come in three main flavors:
| Tag | Based on | Notes |
|---|---|---|
python:3.12 | Full Debian with build tools | Largest; useful only as a build stage |
python:3.12-slim | Minimal Debian | Good default: glibc, apt available, small |
python:3.12-alpine | Alpine Linux (musl) | Smallest, but wheels built for glibc do not apply, so some packages compile from source |
The slim variant is the safest choice for most Python applications. Alpine can be slower to build and occasionally behaves differently because of musl, so choose it only when you have tested your dependencies on it.
Step 3 - Adding a .dockerignore file
The build context is everything in the directory you pass to docker build. Without a .dockerignore file, COPY . . also copies Git history, virtual environments, local secrets and caches into the image. Create one:
nano .dockerignore
.git
.gitignore
.venv
__pycache__
*.pyc
.env
Dockerfile*
.dockerignore
Excluding .env also prevents local credentials from ending up in an image layer, where anyone who pulls the image can read them.
Step 4 - Ordering layers and cleaning up in the same layer
Each instruction creates a layer, and Docker reuses a cached layer only if the instruction and everything before it are unchanged. Put what changes rarely (the base image, system packages, dependencies) first and what changes often (your source code) last.
Files deleted in a later RUN still exist in the earlier layer and still count toward the image size. Clean up in the same RUN instruction that created the files.
Write an improved single-stage Dockerfile:
nano Dockerfile.slim
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --create-home --uid 10001 appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
If you do need system packages, install them in one instruction and remove the apt lists in the same step:
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
Build and compare:
docker build -f Dockerfile.slim -t imgdemo:slim .
docker image ls imgdemo
REPOSITORY TAG IMAGE ID CREATED SIZE
imgdemo slim 2c7d9e1f4a6b 5 seconds ago 146MB
imgdemo naive 8f3a1c2b9d4e 3 minutes ago 1.1GB
Now change only the application code and rebuild:
sed -i 's/optimized image/optimized image, v2/' app.py
docker build -f Dockerfile.slim -t imgdemo:slim .
In the build output, the pip install step shows CACHED: only the COPY app.py layer and the ones after it are rebuilt, so the build takes a couple of seconds.
Step 5 - Using BuildKit cache mounts
--no-cache-dir keeps pip's download cache out of the image, but it also means every rebuild after a change to requirements.txt downloads every package again. A BuildKit cache mount gives you both: the cache persists on the build host between builds, and it is never written into the image.
Open Dockerfile.slim again and update it so that the first line declares the Dockerfile syntax and the pip install instruction uses a cache mount:
nano Dockerfile.slim
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY app.py .
RUN useradd --create-home --uid 10001 appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
The same pattern works for other package managers, for example target=/root/.npm for npm ci or target=/go/pkg/mod for Go modules. Rebuild to confirm the syntax is accepted:
docker build -f Dockerfile.slim -t imgdemo:slim .
Step 6 - Building a Go service with a multi-stage build
Multi-stage builds pay off most for compiled languages: you compile in a stage that has the full toolchain, then copy only the resulting binary into a minimal final stage. The toolchain never reaches the image you ship.
Create a small Go HTTP server in its own directory:
mkdir ~/godemo
cd ~/godemo
nano main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a distroless image")
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
nano go.mod
module example.com/godemo
go 1.24
Write the multi-stage Dockerfile:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.24 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
EXPOSE 8080
ENTRYPOINT ["/server"]
How it works:
- The
buildstage uses the full Go image (around 800 MB) to compile. CGO_ENABLED=0produces a statically linked binary that needs no C library, and-ldflags="-s -w"strips debug symbols to reduce its size.- The final stage is Google's
distroless/staticimage: CA certificates, time zone data and a non-root user, but no shell and no package manager. The:nonroottag runs the process as UID 65532.
Build it, check the size and run it:
docker build -t godemo:distroless .
docker image ls godemo
docker run -d --name godemo -p 127.0.0.1:8080:8080 godemo:distroless
curl http://127.0.0.1:8080
REPOSITORY TAG IMAGE ID CREATED SIZE
godemo distroless 6b1e4d8a2f90 4 seconds ago 9.5MB
Hello from a distroless image
The image that runs in production is roughly 1% of the build image's size. Because there is no shell, docker exec -it godemo sh fails by design; use a debug container attached to its network namespace when you need to troubleshoot. Remove the test container with docker rm -f godemo.
TipFor a fully static binary you can also use
FROM scratchas the final stage, which is empty. You then have to copy CA certificates yourself if the program makes HTTPS requests, and set a non-rootUSERby numeric ID, which is whydistroless/staticis usually the more practical choice.
Step 7 - Inspecting and scanning the result
docker history shows the size added by each layer, which quickly points at the instruction responsible for bloat:
docker history imgdemo:naive --format 'table {{.Size}}\t{{.CreatedBy}}' | head -8
For an interactive, file-level view of every layer, run the dive tool from its official container image:
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest imgdemo:slim
Finally, scan the images for known vulnerabilities with Trivy. Compare the naive and slim builds to see how many fewer packages, and therefore findings, the smaller image has:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity HIGH,CRITICAL imgdemo:naive
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity HIGH,CRITICAL imgdemo:slim
WarningMounting
/var/run/docker.sockgives the container full control of the Docker daemon. Do it only with trusted, official images like the ones above.
Rebuild regularly with docker build --pull so the base image picks up security updates, and pin base images to a specific version tag (for example python:3.12-slim rather than python:latest) so builds are reproducible.
Troubleshooting
COPY failed: file not found in build context. The file is excluded by .dockerignore or lies outside the directory you passed to docker build. Check the patterns in .dockerignore.
the --mount option requires BuildKit. The legacy builder is in use. Install the docker-buildx-plugin package from Docker's repository, or build with docker buildx build.
The Go container exits with exec /server: no such file or directory. The binary was built with cgo enabled and needs a C library the final image does not have. Build with CGO_ENABLED=0, or use gcr.io/distroless/base-debian12 as the final stage.
pip install compiles packages on Alpine and the build is slow or fails. The package has no musl wheel. Switch to the slim base image, or install the build dependencies in a separate build stage.
Conclusion
You reduced a Python image from over 1 GB to under 150 MB by choosing a slim base, excluding files with .dockerignore, ordering layers for cache reuse and cleaning up in the same layer, sped up rebuilds with BuildKit cache mounts, and shipped a Go service in a distroless image of about 10 MB. As next steps, add a Trivy scan to your CI pipeline so vulnerable images never reach production, build multi-architecture images with docker buildx build --platform linux/amd64,linux/arm64, and read our Docker Compose guide to run your optimized images as a stack.
