Thursday, April 23, 2026

5 Docker best practices for faster builds and smaller images

Share


Photo by the author

# Entry

You wrote a Dockerfile, built an image, and everything works. But then you notice that the image is over a gigabyte in size, rebuilding takes several minutes for even the smallest changes, and each press or pull feels painfully sluggish.

This is not unusual. These are the default results if you write Dockerfiles without thinking about base image selection, build context, and caching. You don’t need a complete overhaul to fix this. A few targeted changes can shrink your image by 60-80% and turn most rebuilds from minutes to seconds.

In this article, we’ll cover five practical techniques that will assist you learn how to make your Docker images smaller, faster, and more effective.

# Prerequisites

To follow along you will need:

  • Docker installed
  • Basic knowledge Dockerfiles and docker build order
  • Python project with requirements.txt file (the examples employ Python, but the rules apply to any language)

# Select Slim or Alpine base images

Each Dockerfile starts with a FROM statement that selects a base image. This base image is the foundation on which your application is built, and its size becomes the minimum image size before adding one line of your own code.

For example, an official python:3.11 image is a full Debian-based image that includes compilers, tools, and packages that most applications never employ.

# Full image — everything included
FROM python:3.11

# Slim image — minimal Debian base
FROM python:3.11-slim

# Alpine image — even smaller, musl-based Linux
FROM python:3.11-alpine

Now build an image from each of them and check the sizes:

docker images | grep python

If you change one line in your Dockerfile, you’ll see a difference of several hundred megabytes. So which one should you employ?

  • slim is the safer default for most Python projects. Removes unnecessary tools but retains C libraries that many Python packages need to install properly.
  • alpine is even smaller, but uses a different C library — muscle instead glibc – which may cause compatibility issues with some Python packages. So you can spend more time debugging failed pip installs than you save on image size.

Rule of thumb: start with python: 3.1x-slim. Switch to Alpine only if you are sure your dependencies are compatible and need additional size reduction.

// Tier ordering to maximize cache

Docker builds images layer by layer, one statement at a time. Once the layer is built, Docker caches it. If nothing changes in the next build that would affect the layer, Docker will reuse the cached version and skip rebuilding it.

Hook: if a layer changes, each subsequent layer will be invalidated and built anew.

This is very crucial when installing dependencies. Here’s a common mistake:

# Bad layer order — dependencies reinstall on every code change
FROM python:3.11-slim

WORKDIR /app

COPY . .                          # copies everything, including your code
RUN pip install -r requirements.txt   # runs AFTER the copy, so it reruns whenever any file changes

Every time you change a single line in the script, Docker invalidates COPY . . layer and then reinstalls all dependencies from scratch. In design with bulky requirements.txtthese are minutes lost on reconstruction.

The solution is uncomplicated: first copy what changes at least.

# Good layer order — dependencies cached unless requirements.txt changes
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .           # copy only requirements first
RUN pip install --no-cache-dir -r requirements.txt   # install deps — this layer is cached

COPY . .                          # copy your code last — only this layer reruns on code changes

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

Now that you’ve changed app.pyDocker reuses the cached pip layer and only re-runs the final version COPY . ..

Rule of thumb: order yours COPY AND RUN instructions from least frequently changed to most frequently changed. Dependencies before code, always.

# Using multi-stage builds

Some tools are only needed at the build stage – compilers, test runners, build dependencies – but they end up in the final image anyway, populating it with elements that the running application never touches.

Multi-stage builds solve this problem. You employ one step to build or install everything you need, then copy only the finished result into a tidy, minimal final image. Build tools never make it into the image you upload.

Here is a Python example where we want to install dependencies but keep the final image:

# Single-stage — build tools end up in the final image
FROM python:3.11-slim

WORKDIR /app

RUN apt-get update && apt-get install -y gcc build-essential
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

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

Now with multi-step build:

# Multi-stage — build tools stay in the builder stage only

# Stage 1: builder — install dependencies
FROM python:3.11-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y gcc build-essential

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: runtime — tidy image with only what's needed
FROM python:3.11-slim

WORKDIR /app

# Copy only the installed packages from the builder stage
COPY --from=builder /install /usr/local

COPY . .

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

The gcc and compilation tools – needed to compile some Python packages – disappeared from the final image. The application still runs because the compiled packages were copied. The build tools themselves remained in the builder stage, which Docker discards. This pattern is even more crucial in Go or Node.js projects, where compiler or node modules hundreds of megabytes in size can be completely excluded from the provided image.

# Cleaning in the installation layer

When installing system packages using apt-getthe package manager takes lists of packages and caches files you don’t need at runtime. If you delete them in a separate file RUN instructions still exist in the middleware, and Docker’s layering system means they still affect the final image size.

To actually remove them, the cleaning must occur in the same place RUN instructions as installation.

# Cleanup in a separate layer — cached files still bloat the image
FROM python:3.11-slim

RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/* # already committed in the layer above

# Cleanup in the same layer — nothing is committed to the image
FROM python:3.11-slim

RUN apt-get update && apt-get install -y curl 
    && rm -rf /var/lib/apt/lists/*

The same logic applies to other package managers and momentary files.

Rule of thumb: everyone apt-get install should be followed && rm -rf /var/lib/apt/lists/* in the same RUN order. Make it a habit.

# Implementing .dockerignore files

When you run docker buildDocker sends everything in the build directory to the Docker daemon as the build context. This happens before any instructions in the Dockerfile are run, and often contains files that you almost certainly don’t want in your image.

Without .dockerignore file, you send the entire project folder: .git history, virtual environments, local data files, test devices, editor configurations, and more. This slows down each build and runs the risk of copying sensitive files into the image.

AND .dockerignore file works exactly like .gitignore; tells Docker which files and folders should be excluded from the build context.

Here is a sample, although truncated, .dockerignore for a typical Python data project:

# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/

# Virtual environments
.venv/
venv/
env/

# Data files (don't bake huge datasets into images)
data/
*.csv
*.parquet
*.xlsx

# Jupyter
.ipynb_checkpoints/
*.ipynb

...

# Tests
tests/
pytest_cache/
.coverage

...

# Secrets — never let these into an image
.env
*.pem
*.key

This significantly reduces the data sent to the Docker daemon before compilation begins. For huge data projects where parquet files or raw CSV files reside in the project folder, this may be the single biggest win of all five practices.

It is worth paying attention to the safety aspect. If your project folder contains .env files with API keys or database credentials, forgetting .dockerignore means that these secrets can be incorporated into your image – especially if you have extensive knowledge COPY . . instruction.

Rule of thumb: Always add .env and any authentication files for .dockerignore except for data files that do not need to be blended into the image. Utilize also Docker secrets for sensitive data.

# Abstract

None of these techniques require advanced Docker knowledge; they are more habits than techniques. Utilize them consistently and your images will be smaller, your builds will be faster, and your deployments will be cleaner.

Practice What fixes it
Basic slim/alpine image

It provides smaller images, starting with only the necessary operating system packages.

Layer order

It avoids reinstalling dependencies every time you change your code.

Multi-stage construction

Excludes creation tools from the final image.

Cleaning the same layer

Prevents middle layers from being bloated by apt cache.

.dockerignore

Reduces build context and protects images from secrets.

Joyful coding!

Priya C’s girlfriend is a software developer and technical writer from India. He likes working at the intersection of mathematics, programming, data analytics and content creation. Her areas of interest and specialization include DevOps, data analytics and natural language processing. She enjoys reading, writing, coding and coffee! He is currently working on learning and sharing his knowledge with the developer community by writing tutorials, guides, reviews, and more. Bala also creates captivating resource overviews and coding tutorials.

Latest Posts

More News