Image Size #
In modern software engineering practice, a Docker image isn’t just a build artifact — it’s part of the application’s supply chain. An oversized image is often the source of hidden problems: slow CI builds, long image pulls in clusters, bloated registry bandwidth, heavy container cold starts, and a wider attack surface. All of these costs repeat on every deployment, on every new node, on every autoscaling event.
This article is a conceptual article discussing the general principles and mindset for shrinking Docker images, without being tied to a specific programming language. For specific technical implementations, each language (Go, Java, Python, Rust, Node.js, Ruby, PHP) is covered separately in follow-up articles.
The main goal of this article is to build the correct mental model: why images bloat, what the main factors are, and what general strategies can be applied across languages and frameworks. Once you understand the foundation, per-language technical discussions no longer feel like a collection of optimization tricks, but rather part of one coherent design approach.
Why Does Image Size Matter? #
Before getting technical, it’s important to understand the impact of image size in production. Large images aren’t just an aesthetic issue — they have real consequences that repeat every day.
1. CI/CD speed. A CI pipeline building a 1 GB image takes far longer than one building a 100 MB image. This happens on every commit, every pull request, every release tag. The total wasted time can be significant.
2. Deploy and scaling time. In Kubernetes, ECS, or Swarm, new nodes must pull the image before containers can run. Large images slow down autoscaling, slow down rolling updates, and slow down incident recovery. During traffic spikes, new nodes take longer to be ready to serve requests.
3. Infrastructure costs. Registry bandwidth, registry storage, and compute time all increase with image size. At the scale of hundreds of deployments per day, these costs are real. Cloud providers often charge for data transfer from registries — large images = higher costs.
4. Security and attack surface. Every package, library, and tool in an image is a potential vulnerability. Large images usually carry more OS packages and unused dependencies. More packages = more CVEs to patch. More tooling = more attack vectors.
5. Operations and debugging. Slim images are easier to understand, audit, and debug. New engineers can understand what’s inside an image faster. Security audits are also easier because there’s less to check.
6. Cold starts for serverless containers. For AWS Fargate, Google Cloud Run, or Azure Container Apps, cold-start time is affected by image size. A 50 MB image can start in 200ms; a 1 GB image can take 3-5 seconds. For interactive workloads, that’s a noticeable user experience difference.
flowchart LR
A[Large Image] --> B[Slow CI Builds]
A --> C[Slow Image Pulls]
A --> D[Slow Deploy/Scaling]
A --> E[Higher Infrastructure Costs]
A --> F[Wide Attack Surface]
A --> G[Hard Debugging]
B & C & D & E & F & G --> H[Expensive & Risky Operations]What Makes Docker Images Large? #
Understanding the root causes of image bloat is the first step toward controlling it. Several common causes occur in almost every language and framework.
1. Oversized Base Images #
The base image determines your image’s size foundation. If you start from ubuntu:latest (~70 MB) or node:latest (~900 MB), you’re already starting with heavy baggage — even before adding a single line of application code.
Small images start from small base images. Large images start from large base images. It sounds obvious, but in production many Dockerfiles still use ubuntu or python:latest without strong technical reasons.
2. Build Tools Leaking into the Runtime Image #
This is the most common cause of unnecessarily large images. When you RUN apt-get install -y gcc or RUN apk add --no-cache build-base in a Dockerfile, those tools are used to compile dependencies. But after the build finishes, there’s no reason to carry a compiler into the runtime image.
A concrete example:
// ✗ Compilers and build tools end up in the runtime image
FROM python:3.12
RUN apt-get update && apt-get install -y gcc libpq-dev
RUN pip install psycopg2
# The image now has gcc, libpq-dev, header files, etc. — none needed at runtime
// ✓ Multi-stage: compiler in the build stage, slim runtime image
FROM python:3.12 AS builder
RUN apt-get update && apt-get install -y gcc libpq-dev
RUN pip install --prefix=/install psycopg2
FROM python:3.12-slim
COPY --from=builder /install /usr/local
# The runtime image only has the psycopg2 library, no compiler
3. Package Manager Caches Not Cleaned #
When you apt-get install, download caches are stored in /var/lib/apt/lists/. When you apk add, caches go to /var/cache/apk/. When you pip install, caches go to ~/.cache/pip/. These caches can be hundreds of MB and won’t be removed automatically.
// ✗ Caches pile up in the image
RUN apt-get update
RUN apt-get install -y curl
# The image has apt caches + curl
// ✓ Caches removed in the same layer as the installation
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
This principle applies to every package manager: apt, apk, yum, dnf, pip, npm, gem, composer. Always remove caches in the same layer as the installation.
4. Oversized Build Contexts #
The Docker daemon receives the entire build context before the build starts. If the context is 500 MB, the build will be slow even before the first instruction runs. Common sources of large contexts:
- Local
node_modules/already installed on the laptop. .git/folders full of history.- Log files, build output, and temporary files.
- Internal documentation.
- Editor files
.vscode/,.idea/. - Large assets (images, videos, datasets).
The solution: a strict .dockerignore.
# .dockerignore
node_modules
.git
.env
*.log
dist
build
.vscode
.idea
.DS_Store
coverage
.dockerignore speeds up builds, shrinks context size, and prevents sensitive data leaks.
5. “One Image Fits All” #
The same image is used for development, testing, CI, and production. As a result, the image carries debugging tools, test runners, documentation, and development dependencies that production doesn’t need.
A cleaner solution:
- A base Dockerfile with minimal setup.
Dockerfile.devwith extra tooling for development.- Multi-target within one Dockerfile using a
targetargument.
# Single Dockerfile with multiple targets
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS dev
RUN npm install --save-dev nodemon
CMD ["npm", "run", "dev"]
FROM base AS prod
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
Build with a target:
docker build --target dev -t myapp:dev .
docker build --target prod -t myapp:prod .
6. Dependencies Not Audited #
Many projects carry dependencies that aren’t actually used, or that only matter in development. Without regular audits, these dependencies pile up in the image.
Audit strategies:
- Separate
requirements.txt(prod) andrequirements-dev.txt. - Use
npm ci --omit=devto install only production dependencies. - Audit periodically with
depcheck(Node.js),pip-autoremove(Python), orcargo machete(Rust). - Remove unused starter/boilerplate dependencies.
Basic Principles for Reducing Image Size #
This section is the core of the whole strategy. These seven principles apply to almost every language and framework.
1. Separate Build and Runtime Environments #
The most fundamental image optimization principle:
What’s needed at build time isn’t necessarily needed at runtime.
The way to achieve it: multi-stage builds. The first stage contains the build tools. The second stage only copies the needed artifacts. Compilers, header files, and build tools stay in the first stage and never enter the final image.
flowchart LR
subgraph Build[Build Stage - discarded]
A[Base Image + Compiler]
B[Source Code]
C[Compile / Build]
A --> B --> C
end
C --> D[Artifact / Binary]
subgraph Runtime[Runtime Stage - shipped]
E[Minimal Base Image]
D --> F[Copy Artifact]
F --> G[Slim Image]
end2. Choose the Right Base Image #
The ideal base image is the one best suited to the runtime needs, not the absolutely smallest one.
A general ordering from small to large:
| Base Image | Size | Use Case |
|---|---|---|
scratch | 0 MB | Go, Rust static binaries |
distroless | 10-30 MB | Java, Node.js, Python production |
alpine | 5-10 MB | Languages with glibc compatibility issues |
-slim | 50-150 MB | The safe default |
| default tags | 200-900 MB | Development, prototyping |
ubuntu/debian | 70-100 MB | When full OS tools are needed |
Base images aren’t about “the smallest”, but “the most appropriate”.
Sometimes a tiny alpine image can’t be used due to library compatibility. A larger slim image is the right choice. What matters is awareness of the trade-offs and consistency in decisions.
3. Minimize the Final Image’s Contents #
Ask yourself, for every file and tool in the image:
- Is this truly needed when the application runs?
- Is this only for debugging?
- Is this documentation relevant in production?
- Is this a dev dependency that slipped in?
The principle: a production image should only contain what’s needed to run the application. Nothing more.
4. Optimize Layers #
Clean Docker layers produce smaller images and faster builds.
General principles:
- Combine related commands into one
RUN. - Remove caches and temporary files in the same layer that creates them.
- Order instructions for effective caching.
- Avoid a standalone
apt-get updateseparated fromapt-get install— always combine them.
// ✗ Three layers, caches not cleaned
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
// ✓ One layer, everything clean
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
5. Manage the Build Context with .dockerignore
#
.dockerignore is a weapon that’s often underestimated. A large build context = slow builds, and potential data leaks.
Ideally, Docker only receives files relevant to the build:
- Application source code.
- Build configuration files.
- Dependency definition files (
package.json,requirements.txt,Cargo.toml).
Not:
- Locally installed dependencies (
node_modules,vendor). - Old build artifacts (
dist,build,target). - Editor and OS files.
- Secret files (
.env, credentials).
6. Distinguish Development and Production Images #
Images for development may be larger because they’re used for fast iteration. Production images must be minimal because they’ll be deployed thousands of times.
Common strategies:
- Different targets in one Dockerfile (see the multi-target example above).
- Separate Dockerfiles for dev and prod.
- Override the entrypoint in development images for hot reload.
7. Audit and Clean Up Regularly #
Image size isn’t something you “set once and forget”. Dependencies change, base images get updates, and best practices evolve. Regular auditing is a small investment that prevents images from bloating over time.
Cross-Language Technical Strategies #
Although every language has nuances, there are some technical strategies that apply generally.
Choose Language-Appropriate Base Images #
| Language | Recommended Base Image | Notes |
|---|---|---|
| Go | gcr.io/distroless/static, alpine, scratch | Go produces static binaries |
| Java | gcr.io/distroless/java17, eclipse-temurin:*-jre-alpine | JRE only, no JDK |
| Node.js | gcr.io/distroless/nodejs20, node:*-slim, node:*-alpine | Avoid node:latest |
| Python | python:*-slim, python:*-alpine | Avoid python:latest |
| Rust | gcr.io/distroless/cc, debian:*-slim, alpine, scratch | Needs glibc for dynamic linking |
| PHP | php:*-fpm-alpine | Plus composer in the build stage |
| Ruby | ruby:*-slim, ruby:*-alpine | Avoid ruby:latest |
Separate Production vs Dev Dependencies #
Almost every package manager has a way to separate dependencies:
- Node.js:
npm ci --omit=devor separatedependenciesvsdevDependenciesinpackage.json. - Python: separate
requirements.txt(prod) andrequirements-dev.txt. - Ruby:
bundle config set without 'development test'. - PHP:
composer install --no-dev. - Go:
go mod tidyensures only imported dependencies are included. - Rust:
Cargo.toml[dependencies]vs[dev-dependencies]. - Java/Maven:
<scope>provided</scope>or<scope>test</scope>inpom.xml.
Package Manager Optimizations #
Each package manager has options to shrink its output:
- apt:
apt-get install -y --no-install-recommends(skip optional recommendations). - pip:
pip install --no-cache-dir(skip cache). - npm:
npm ci --omit=dev --no-audit(skip dev and audit). - gem:
bundle install --no-cacheorgem: --no-document. - composer:
composer install --no-dev --optimize-autoloader --no-scripts.
Use the Layer Cache Deliberately #
Dockerfile instruction order is a caching strategy:
// A pattern that maximizes caching
FROM base:tag
WORKDIR /app
# Step 1: OS dependencies (rarely change)
RUN apt-get update && apt-get install -y --no-install-recommends \
package1 package2 \
&& rm -rf /var/lib/apt/lists/*
# Step 2: Application dependency definitions (rarely change)
COPY package*.json ./
RUN npm ci
# Step 3: Source code (often changes)
COPY . .
# Step 4: Build (depends on source)
RUN npm run build
With this pattern:
- The OS dep cache is reused as long as OS dependencies don’t change.
- The app dep cache is reused as long as
package*.jsondoesn’t change. - The source code layer is rebuilt on every code change — but layers after it are unaffected.
Measuring and Monitoring Image Size #
Optimization without measurement is just guessing. Some tools you can use to measure and monitor:
Built into Docker:
# View image size
docker images myapp
# Per-layer size details
docker history myapp
Third-party tools:
- dive — an interactive tool to explore layers and see what changes in each layer.
- docker-slim — automated image minification. Can shrink images further by removing unused files.
- crane — extract image metadata and view manifests.
CI/CD integration:
Add an image-size measurement step to the pipeline:
SIZE=$(docker images myapp:latest --format "{{.Size}}")
echo "Image size: $SIZE"
# Fail the build if the size is above a threshold
Dashboards:
For long-term monitoring, export image sizes to a metrics system (Prometheus, Datadog, etc.) and set up alerts if sizes suddenly bloat.
Trade-offs You Must Understand #
Image size optimization has trade-offs you need to be aware of. There’s no always-correct solution.
Distroless vs Alpine vs Slim:
| Aspect | Distroless | Alpine | Slim |
|---|---|---|---|
| Size | Very small | Small | Medium |
| libc | glibc | musl | glibc |
| Shell | No | Yes | Yes |
| Package manager | No | Yes (apk) | Yes (apt) |
| Debugging | Hard | Easy | Easy |
| Binary stability | High | Sometimes problematic | High |
| Best for | Mature production | Default | Safe default |
When to choose what:
- Distroless — high-maturity production, security-first, observability already solid.
- Alpine — needs tools in the container, size matters, aware of musl issues.
- Slim — the safe default, doesn’t want compatibility risks.
- Default tags — development, prototyping, or full OS tools needed.
Size vs observability:
Very small images (distroless, scratch) make interactive debugging hard. You can’t docker exec -it container sh. The solution: invest in observability — structured logging to STDOUT, metrics endpoints, distributed tracing. A small image + good observability beats a large image + manual debugging.
Size vs build time:
Highly optimized images often take longer to build (multi-stage, compilation, etc.). This trade-off is usually worth it for production, but can be a burden in development.
The Positive Impact of Slim Images #
If the principles above are applied correctly, the impact is immediately felt:
- Faster CI builds — caching works optimally, repeated builds don’t start from zero.
- Lighter image pulls — new nodes are ready faster.
- More responsive deployments — rolling updates finish faster, recovery is shorter.
- Reduced security surface — fewer CVEs to track.
- Lower infrastructure costs — bandwidth, storage, and compute are more economical.
- Faster cold starts — important for serverless and autoscaling.
- More disciplined container architecture — engineers are pushed to understand the build vs runtime boundary.
And most importantly: a slim image reflects engineering maturity. A sloppy image is usually written by an engineer who doesn’t yet understand the trade-offs. A slim image is written with full awareness of cost, security, and operations.
Summary #
- Image size isn’t an aesthetic issue — it directly impacts CI/CD, scaling, costs, security, and cold starts.
- Main causes of image bloat: large base images, build tools leaking into runtime, uncleaned caches, large build contexts, and unaudited dependencies.
- Key principles: separate build vs runtime, choose the right base image, minimize the final image, optimize layers, and manage
.dockerignore.- Multi-stage builds are the standard for separating build environments from runtime. Compilers and build tools must not enter the final image.
- Distinguish development and production images — production images must be minimal, development images may carry tools.
- Audit dependencies regularly — separate prod vs dev dependencies, remove what’s unused.
- Understand the trade-offs: distroless vs alpine vs slim has size-vs-observability trade-offs, and small images require observability investment.
- Measure and monitor — optimization without measurement is just guessing. Use
docker history,dive, and CI integration for monitoring.- A slim image is a reflection of engineering maturity. It’s not an end goal, but a side effect of a clean build architecture.