Reduce Docker Image Size Without Broken Builds
AI generated
Docker · Image Optimization · Multi-Stage · DevOps
Reduce Docker Image Size
Without Broken Builds

A 1.2 GB Docker image is not a sign of completeness, it is a sign of missing optimization. Multi-stage builds, precise layer order, a well thought out .dockerignore and the right choice of base image make the difference between a lean, fast CI build and a deployment bottleneck.

14 min read Multi-Stage · Alpine · Distroless · .dockerignore · Layer Cache Docker 25+ · BuildKit

1. Why image size really matters

The Docker image size is not merely an aesthetic concern. Large images slow down every step of the deployment pipeline: longer build times, slower registry pushes, longer pull times on production servers and higher storage costs in container registries. In CI/CD systems that pull images fresh on every build, that overhead adds up to measurable hours of wasted pipeline time over the course of a few weeks.

Security is a second, often underestimated reason. An image containing unnecessary build tools, shell interpreters and package manager caches carries a considerably larger attack surface than a minimized runtime image. Any package that is not present in the final image cannot be compromised. Reducing the Docker image size and reducing the attack surface are therefore the same measure with two justifications.

The most common pattern behind oversized images is the lack of a clean separation between the build environment and the runtime environment. A Node.js image that ships all its devDependencies, a PHP image with Composer, Xdebug and PHPStan, or a Java image with the full JDK instead of just the JRE, all of these produce images that are many times larger than necessary. Multi-stage builds are the direct fix for this problem.

2. Analyzing image content: what takes up the space?

Before you can reduce Docker image size, you need to know what is taking up space. The docker history tool shows every layer of an image along with its size and the Dockerfile instruction that created it. This makes it easy to spot which RUN command produces the largest layer and whether package manager caches are ending up in the image by mistake. docker image inspect returns metadata, but no detailed layer analysis.

For deeper analysis there is the external tool dive, which lets you interactively inspect every layer of an image and shows which files were added, changed or deleted. Particularly revealing is the view showing which files were deleted between layers but are still physically present in the image storage, because RUN rm -rf /var/cache in a separate layer does not actually remove the files from the image, it merely adds a deletion marker. Understanding this layer mechanism is the key to effective Docker image size reduction.


# Analyze image layers and sizes
docker history my-image:latest --no-trunc --format "table {{.CreatedBy}}\t{{.Size}}"

# Install dive for interactive layer inspection
# https://github.com/wagoodman/dive
docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:latest my-image:latest

# Show image total size and layer count
docker image inspect my-image:latest | jq '.[0] | {Size: .Size, Layers: (.RootFS.Layers | length)}'

# Compare image sizes before and after optimisation
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" | grep my-image

# Find all images sorted by size (largest first)
docker images --format "{{.Size}}\t{{.Repository}}:{{.Tag}}" | sort -rh | head -20

3. Multi-stage builds: separating build tools from the runtime image

Multi-stage builds are the most effective way to drastically reduce the Docker image size. The principle is simple: a first stage uses a full build image with every compiler, package manager and test tool it needs. A second stage uses a minimal runtime image and copies in only the finished artifacts from the build stage. The build stage is discarded at the end, it never appears in the final image. With this approach, PHP images shrink from 800 MB to 120 MB, and Node.js images from 1.2 GB to 80 MB.

An important aspect of multi-stage builds that is often overlooked: you can selectively copy individual files and directories from several different stages into the final stage. A build can have one stage for frontend assets (Node.js based) and one for backend code (PHP based), and the final stage pulls the finished results from both. The --from=stage-name syntax in COPY makes this possible. Stages can also be named (FROM node:22 AS frontend), which produces more readable Dockerfiles than numeric stage references.


# Multi-stage Dockerfile: PHP application with frontend assets
# Stage 1: Frontend build (Node.js)
FROM node:22-alpine AS frontend
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY web/tailwind/ ./web/tailwind/
# Build CSS and JS assets
RUN npm run build

# Stage 2: PHP dependencies (Composer)
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# Install only production dependencies, skip dev tools
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist \
    --optimize-autoloader

# Stage 3: Final runtime image (minimal)
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache nginx

WORKDIR /var/www/html
# Copy only compiled assets from frontend stage
COPY --from=frontend /build/web/dist/ ./web/
# Copy only vendor directory from composer stage
COPY --from=vendor /app/vendor ./vendor/
# Copy application code
COPY app/ ./app/
COPY pub/ ./pub/

# No build tools, no node_modules, no composer: final image is minimal
EXPOSE 80 9000
CMD ["php-fpm"]

4. Layer order and cache efficiency

The order of instructions in a Dockerfile has a direct impact on build speed and Docker image size. Docker layers are cached, and a changed layer invalidates every layer that follows it. The fundamental principle is: rarely changing, large layers go first, frequently changing code goes last. Package installations change less often than application code, so install system packages first, then copy and install dependency files, then copy the actual code.

A common layer optimization: combine several RUN commands that logically belong together into a single RUN instruction. Every RUN command creates a new layer. If you install packages in one layer and clear the cache in the next layer, you have cleared the cache, but the original layer containing that cache is still part of the image. The fix: perform installation and cache cleanup within the same RUN command. Commands joined with && share a single layer and leave no cache artifact behind.

5. Configuring .dockerignore correctly

The .dockerignore file is one of the simplest ways to reduce Docker image size, and yet it is frequently neglected or left out entirely. Without it, COPY . . copies everything into the build context: node_modules with hundreds of megabytes, the .git directory, local environment files, IDE configuration, test data and build artifacts from previous local builds. This first slows down the build context transfer to the Docker daemon and can then pull unwanted files into the image.

A good .dockerignore file follows the whitelist principle: exclude everything first (**), then explicitly include only what is needed (!src/, !composer.json). This inverse strategy is safer than listing every path to exclude, because new directories are automatically excluded and never need to be added to an exclude list manually. It also prevents sensitive files such as .env files or private keys from being accidentally included.


# .dockerignore: whitelist approach, exclude everything, include only what's needed
**

# Include application source
!src/
!app/
!pub/

# Include dependency manifests (not the resolved dependencies)
!composer.json
!composer.lock
!package.json
!package-lock.json

# Include configuration files needed at build time
!docker/
!Makefile

# Explicitly exclude secrets even if accidentally matched above
**/.env
**/.env.*
**/secrets/
**/*.key
**/*.pem

# Typical directories that should never be in the build context
# node_modules/         : already excluded by ** but explicit for clarity
# vendor/               : rebuilt in multi-stage
# .git/                 : included in ** exclusion
# var/cache/ var/log/   : runtime artifacts

6. Choosing a base image: Alpine, slim, distroless

The choice of base image is the most fundamental decision affecting Docker image size. Alpine Linux, at roughly 5 MB, is the smallest general purpose Linux base image and comes with apk as its package manager. It suits most applications, but it has its quirks: instead of glibc, Alpine uses musl libc, which can cause subtle issues with certain native extensions, especially PHP extensions that rely on glibc specifics. For PHP, Alpine is largely stable these days, but individual extensions sometimes need to be compiled from source rather than installed from precompiled binaries.

Google's distroless images go a step further: they contain only the runtime libraries, with no shell, no package manager and no other utilities. The result is an image with a minimal attack surface: an attacker who gets into the container has no shell, no package manager and no curl to pull in further tools. Distroless images work best for Go binaries (which need no external dependency at all) and for Java runtimes. For PHP they are less practical because of the complexity of extension dependencies. The -slim variants of the official Docker images are a good compromise: Debian based with a reduced package set, but with a full shell and apt still available.

7. Eliminating package manager caches in the build

Every package manager builds up a local cache during installation. apt fills /var/cache/apt/archives, apk fills /var/cache/apk, pip fills ~/.cache/pip, npm fills ~/.npm. These caches are useless at runtime and only inflate the image with no benefit. The fix is to combine installation and cache cleanup into a single RUN command, so the cache never ends up in a permanent layer.

Docker BuildKit offers a more elegant approach: mount caches. RUN --mount=type=cache,target=/var/cache/apt apt-get install -y nginx uses a transient cache that is reused across builds but never appears in the image's final layer stack. That gives you the speed benefit of caching for fast rebuilds without increasing the Docker image size. This technique is especially valuable for images that install many system packages, or where the apt installation step runs frequently during the build.


# Dockerfile: eliminate package manager caches in every layer

# apt: install and clean in same RUN command (without BuildKit)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        nginx \
        libpng-dev \
    && rm -rf /var/lib/apt/lists/* \
    && apt-get clean

# apk (Alpine): --no-cache skips the cache entirely
RUN apk add --no-cache \
    nginx \
    php84-fpm \
    php84-pdo_mysql

# BuildKit mount cache: cache reused across builds, never lands in image layer
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends nginx

# pip: disable cache entirely
RUN pip install --no-cache-dir -r requirements.txt

# npm: use ci (clean install) and cache with BuildKit
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

8. BuildKit features for even smaller images

Docker BuildKit, enabled by default since Docker 23, offers features that go beyond classic Dockerfile optimizations. Secret mounts let you use credentials during the build without ever storing them in the image: RUN --mount=type=secret,id=npmrc cat /run/secrets/npmrc > ~/.npmrc && npm ci. The secret file is only accessible during that one RUN step and never appears in any layer. This is the safe way to feed private registry authentication into builds without immortalizing tokens in the image.

SSH mounts let you use SSH agent authentication during the build, for example to clone private Git repositories without copying SSH keys into the image. Bind mounts let you temporarily mount host directories into the build without copying them. For very advanced Docker image size optimization there is --squash (experimental): it collapses every layer into a single one and thereby eliminates all traces of deleted files from intermediate layers. More effective than squash, though, is consistent multi-stage design, which prevents those problematic intermediate layers from ever appearing in the first place.

9. Comparing image strategies

The various optimization strategies can be combined and come with different effort to benefit ratios. Anyone looking to systematically reduce Docker image size should start with the high impact measures.

Strategy Typical savings Effort Risk
Multi-stage build 60-85% reduction Medium Low with correct COPY
Alpine instead of Debian 200-400 MB Low musl libc compatibility
.dockerignore 50-500 MB (node_modules) Very low Very low
Cache cleanup 20-100 MB Very low Low
Distroless 30-60 MB vs. Alpine High No shell based debugging

The combination of multi-stage builds and a precise .dockerignore delivers the biggest effect for the lowest risk. Alpine as a base image is a safe second measure. Choosing distroless is an advanced measure with a high security payoff, but it requires the team to be able to do without shell based debugging inside the container and to keep all debugging tools available externally instead.

Mironsoft

Docker optimization, CI/CD acceleration and container security

Docker images that build fast and take up little space?

We analyze your Dockerfiles, identify unnecessary layers and implement multi-stage builds along with BuildKit optimizations, with measurable results in image size and CI build time.

Dockerfile audit

Layer analysis with dive, identification of cache problems and unnecessary build artifacts

Multi-stage refactoring

Separating build tools from the runtime image, typically 60-85% size reduction

BuildKit optimization

Mount caches, secret mounts and parallel build stages for maximum CI speed

10. Summary

Reducing Docker image size is not a single intervention, it is a combination of several strategies: multi-stage builds separate build tooling from the runtime image and typically deliver a 60-85% size reduction. A precise .dockerignore file prevents node_modules, .git and local development files from ever entering the build context. Layer order and combining installation with cache cleanup in a single RUN command eliminate unnecessary layers. Alpine as a base image saves 200-400 MB compared to Debian, without significant drawbacks for most applications.

BuildKit features such as mount caches and secret mounts round out the optimization strategy: faster builds through cache reuse without inflating the image, and secure credential handling without leaking tokens into layers. The result is images that build quickly in CI pipelines, cost less to store in registries and offer a smaller attack surface. Analysis tools such as docker history and dive help you understand the current state and measure the effect of every optimization.

Reducing Docker image size: the essentials at a glance

Multi-stage builds

Build stage with all the tools, runtime stage with only the artifacts, typically 60-85% size reduction. Name your stages for readable Dockerfiles.

.dockerignore

Whitelist approach: exclude everything first, then include only what's needed. Prevents accidentally including .env and node_modules.

Layer optimization

Installation and cache cleanup in the same RUN command. Rarely changing layers first for optimal layer caching in CI.

Base image

Alpine for small images with shell access. Distroless for maximum security without a shell. -slim as a compromise for Debian compatibility.

11. FAQ: Reducing Docker image size

1Biggest lever for image size reduction?
Multi-stage builds: build tools never end up in the final image. One refactor, typically 60-85% savings.
2Why doesn't RUN rm actually remove files?
Layers are additive. rm in a separate layer leaves a deletion marker, but the previous layer remains. Always combine installation and cleanup in the same RUN command.
3Risks of Alpine as a base image?
musl libc instead of glibc; most extensions work fine, some need to be compiled from source. Confirm with tests.
4Avoid node_modules in multi-stage?
Build stage: install npm and compile assets. Final stage: only the compiled assets via COPY --from. node_modules never enters the final image.
5.dockerignore whitelist vs. blacklist?
Whitelist (** then !src/) is safer: new directories are excluded automatically. Blacklist has to be maintained manually.
6Layer analysis: what tools exist?
docker history --no-trunc for a quick layer overview. dive for interactive, file level analysis of all layers.
7What are BuildKit mount caches?
Reuse package manager caches across builds without storing them in an image layer. Faster rebuilds without inflating the image.
8Distroless for PHP applications?
Only in advanced setups. PHP needs many runtime libraries. Alpine or debian:slim are more practical. Distroless is ideal for Go binaries.
9Protect .env files from being included?
In .dockerignore: **/.env and **/.env.*. Automatically excluded with the whitelist approach. Check docker history for final confirmation.
10What does --no-install-recommends do?
Installs only the directly requested packages and their strictly necessary dependencies. Typically 30-50% less package weight than without this flag.