--- title: "How to Optimize Docker Images: Smaller and Faster Builds" description: "Shrink Docker images with slim bases, layer ordering, .dockerignore, BuildKit cache mounts and multi-stage builds, then inspect and scan the result with Trivy." category: "Docker Kubernetes" language: en source: https://cubepath.com/docs/docker-kubernetes/docker-image-optimization updated: 2026-09-25 --- # How to Optimize Docker Images: Smaller and Faster Builds 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 `sudo` privileges, added to the `docker` group. - 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: ```bash mkdir ~/imgdemo cd ~/imgdemo nano app.py ``` ```python from flask import Flask app = Flask(__name__) @app.route("/") def index(): return "Hello from an optimized image\n" ``` ```bash nano requirements.txt ``` ```text flask==3.1.0 gunicorn==23.0.0 ``` Now write the kind of Dockerfile that is common in first attempts: ```bash nano Dockerfile.naive ``` ```dockerfile 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: ```bash docker build -f Dockerfile.naive -t imgdemo:naive . docker image ls imgdemo ``` ```output 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: ```bash nano .dockerignore ``` ```text .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: ```bash nano Dockerfile.slim ``` ```dockerfile 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: ```dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends libpq5 \ && rm -rf /var/lib/apt/lists/* ``` Build and compare: ```bash docker build -f Dockerfile.slim -t imgdemo:slim . docker image ls imgdemo ``` ```output 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: ```bash 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: ```bash nano Dockerfile.slim ``` ```dockerfile # 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: ```bash 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: ```bash mkdir ~/godemo cd ~/godemo nano main.go ``` ```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)) } ``` ```bash nano go.mod ``` ```text module example.com/godemo go 1.24 ``` Write the multi-stage Dockerfile: ```bash nano Dockerfile ``` ```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 `build` stage uses the full Go image (around 800 MB) to compile. - `CGO_ENABLED=0` produces 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/static` image: CA certificates, time zone data and a non-root user, but no shell and no package manager. The `:nonroot` tag runs the process as UID 65532. Build it, check the size and run it: ```bash 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 ``` ```output 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`. > Tip: For a fully static binary you can also use `FROM scratch` as the final stage, which is empty. You then have to copy CA certificates yourself if the program makes HTTPS requests, and set a non-root `USER` by numeric ID, which is why `distroless/static` is 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: ```bash 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: ```bash 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: ```bash 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 ``` > Warning: Mounting `/var/run/docker.sock` gives 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.