Docker Image Optimization: Build Smaller, Faster and Safer Images

Practical Dockerfile best practices for beginners: smaller base images, .dockerignore, multi-stage builds, cache mounts and non-root users, with measured results and a Docker commands cheat sheet.

Docker Image Optimization cover: an optimized Dockerfile using a pinned slim Python base image, a pip cache mount and a non-root user, with measured results of 1.15 GB before, 124 MB after and a 2.2 second rebuild

Introduction

Docker image optimization means building images that contain only what your app needs to run. Nothing more.

It matters because every extra megabyte has a cost. Big images take longer to push, pull and deploy. They use more disk and bandwidth. They also ship more software, and more software means more security vulnerabilities to patch.

In this guide, we take a simple Python app from 1.15 GB to 124 MB, and cut rebuild time from 31.5 seconds to 2.2 seconds. Every number below was measured, not estimated.

Key Optimization Techniques

1. Use Minimal Base Images

The base image is the starting point in your FROM line. The default python:3.12 image includes compilers and many system tools. The slim variant keeps only what Python needs to run.

Base imageSize on my machine
python:3.121.11 GB
python:3.12-slim119 MB

Start with slim. Alpine images are smaller still, but they use a different C library (musl), so some Python packages need compiling. That can make builds slower and less predictable.

2. Use Multi-Stage Builds

A multi-stage build uses one image to build and a second, smaller image to run. You copy only the finished result into the final image. Compilers and build tools stay behind.

FROM python:3.12.15-trixie AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.12.15-slim-trixie
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# ...then copy app.py, add the non-root user and CMD, as in the full example below

An honest note: for my small Flask app, this produced a 135 MB image, slightly larger than the single-stage slim build (124 MB). The copied virtual environment includes its own pip. Multi-stage builds pay off when packages need compilers or system libraries to install, or for compiled languages like Go.

3. Optimize Dockerfile Layers

Each RUN, COPY and ADD instruction creates a layer, which is like a saved snapshot. Docker reuses unchanged layers from cache.

Two rules help most:

  • Order from least to most often changed. Copy requirements.txt and install dependencies before copying your code. Then a code change does not reinstall every package.
  • Combine commands that create and clean up files in the same RUN, so temporary files never reach a layer.

4. Use .dockerignore

COPY . . sends your whole folder to Docker, including things the app never uses. A .dockerignore file works like .gitignore:

.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.env
Dockerfile*
.dockerignore

Without it, my basic image contained a 36 MB local .venv and the .git folder. Excluding .env also keeps secrets out of the image.

5. Use BuildKit Cache Mounts

BuildKit is Docker's build engine, enabled by default since Docker Engine 23.0. A cache mount keeps pip's download cache between builds, but outside the image:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Rebuilds after a dependency change download less, and the cache adds nothing to the image size.

6. Remove Unnecessary Dependencies

Install only what production needs. Keep test and lint tools such as pytest in a separate requirements-dev.txt, and install that only in development or CI.

7. Run as a Non-Root User

Containers run as root by default. If an attacker breaks into your app, root makes the damage worse. Create a normal user and switch to it:

RUN useradd --create-home --uid 10001 appuser
USER appuser

8. Pin Base Image Versions

python:3.12 silently changes when a new patch release comes out. Pin the full version and OS release, for example python:3.12.15-slim-trixie, so builds are reproducible. For strict reproducibility, also pin the image digest (@sha256:...), and update it on purpose.

Practical Example: Optimizing a Python App

Our app is a small Flask service (app.py) with flask==3.1.2 in requirements.txt.

Before: a basic Dockerfile

FROM python:3.12

WORKDIR /app

COPY . .

RUN pip install -r requirements.txt

CMD ["python", "app.py"]

It works, but it uses a 1.11 GB base image, copies everything in the folder, reinstalls packages on every code change and runs as root.

After: an optimized Dockerfile

# syntax=docker/dockerfile:1
FROM python:3.12.15-slim-trixie

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Install dependencies first so this layer is reused when only code changes
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Create an unprivileged user to run the app
RUN useradd --create-home --uid 10001 appuser

COPY app.py .

USER appuser

EXPOSE 8000
CMD ["python", "app.py"]

What changed:

  • Pinned slim base image: smaller and reproducible.
  • **PYTHONDONTWRITEBYTECODE** stops Python writing .pyc files into the image. **PYTHONUNBUFFERED** shows logs immediately.
  • Dependencies before code: code changes reuse the cached install layer.
  • Cache mount: faster dependency installs, with no pip cache left in the image.
  • **COPY app.py .** copies only the code, together with the .dockerignore above.
  • Non-root user: the container runs as appuser.

For a real deployment, run the app with a production server such as Gunicorn instead of Flask's built-in server.

Measure the Results

Build each version with its own tag, then compare:

docker build -f Dockerfile.basic -t my-app:before .
docker build -f Dockerfile -t my-app:after .

docker images my-app
docker image inspect my-app:after --format '{{.Size}}'
docker history my-app:after

docker images shows a readable size. docker image inspect gives the exact size in bytes. docker history shows how much each layer adds, which points you at the biggest step.

My results (Docker 29.6.1, Linux, same app and dependencies):

VersionImage sizeRebuild after a code change
Basic Dockerfile1.15 GB31.5 s
Basic + .dockerignore1.12 GBn/a
Optimized (above)124 MB2.2 s
Multi-stage135 MBn/a

To compare fairly:

  • Build from the same code and dependencies.
  • Compare the same metric. Local size is uncompressed; registry size is compressed and smaller.
  • Change one thing at a time to see what each step contributes.
  • Measure rebuild time after a code-only change, since that is what you do all day.

Common Mistakes

  • Large base images by default. Most apps do not need a full OS toolchain at runtime.
  • Copying the entire project. Without .dockerignore, .git, .venv and even .env end up in the image.
  • Development dependencies in production. Test tools add size and attack surface.
  • Ignoring build cache. Copying code before installing dependencies makes every build reinstall everything.
  • Deleting files in a later layer. A file added in one RUN stays in that layer even if a later RUN deletes it. In my test, creating a 100 MB file and deleting it in a separate step left the image at 219 MB. Doing both in one RUN kept it at 119 MB. Avoid adding the file in the first place.

Docker Commands Cheat Sheet

The commands you need most while building and optimizing images. Replace my-app with your image name.

Build images

CommandWhat it does
docker build -t my-app:1.0 .Build an image from the Dockerfile in this folder and tag it
docker build -f Dockerfile.prod -t my-app .Build from a different Dockerfile
docker build --no-cache -t my-app .Rebuild every layer, ignoring the cache
docker build --target builder -t my-app:build .Build only up to one stage of a multi-stage build
docker build --progress=plain .Show full build output, useful for debugging
docker build --platform linux/amd64 -t my-app .Build for a specific CPU architecture

Inspect images

CommandWhat it does
docker imagesList images and their sizes
docker image ls --filter reference='my-app*'List only matching images
docker history my-appShow each layer and how much it adds
docker image inspect my-app --format '{{.Size}}'Exact image size in bytes
docker system dfDisk used by images, containers, volumes and build cache
docker scout quickview my-appSummary of known vulnerabilities, if Docker Scout is installed

Run and manage containers

CommandWhat it does
docker run --rm -p 8000:8000 my-appRun a container, map port 8000, remove it on exit
docker run -d --name web -p 8000:8000 my-appRun in the background with a name
docker run --env-file .env my-appPass environment variables from a file at runtime
docker run -it --rm my-app shOpen a shell in a new container
docker ps / docker ps -aList running / all containers
docker logs -f --tail 100 webFollow the last 100 log lines
docker exec -it web shOpen a shell in a running container
docker exec web whoamiCheck which user the app runs as
docker stats --no-streamOne snapshot of CPU and memory use
docker stop web / docker rm webStop / remove a container

Share images

CommandWhat it does
docker loginSign in to a registry
docker tag my-app:1.0 user/my-app:1.0Add a registry name to an image
docker push user/my-app:1.0Upload an image
docker pull python:3.12.15-slim-trixieDownload an image
docker buildx imagetools inspect python:3.12.15-slim-trixieShow an image's digest for pinning, without pulling it

Clean up

CommandWhat it does
docker rmi my-app:oldRemove an image
docker image pruneRemove dangling (untagged) images
docker image prune -aRemove every image not used by a container
docker builder pruneClear the build cache, including cache mounts
docker system pruneRemove stopped containers, unused networks, dangling images and build cache

Prune commands ask for confirmation and cannot be undone. Add --volumes to docker system prune only if you are sure: it also deletes unused anonymous volumes, which can hold data.

Docker Image Optimization Checklist

  • Use a slim base image, pinned to a full version
  • Add a .dockerignore (.git, .venv, caches, .env)
  • Copy dependency files and install them before copying code
  • Use a BuildKit cache mount for package downloads
  • Install production dependencies only
  • Use a multi-stage build when installs need compilers
  • Run as a non-root user
  • Check results with docker images and docker history
  • Confirm the user with docker exec <container> whoami

Conclusion

Docker image optimization is a handful of small habits with large results. A smaller base image and a .dockerignore cut image size. Smart layer order and cache mounts make builds faster. Smaller images deploy faster, and a non-root user with fewer packages reduces security risk. Measure before and after, and let the numbers guide each change.

Related posts:

References