Mid-level 21 min · March 06, 2026
Optimising Docker Images

Docker Image Bloat — 1.2GB Java Image Killed Friday Deploy

A 1.2GB Java image caused 8-minute CI builds and pull timeouts on EKS.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Docker images are built as layers; each instruction adds a new read-only layer
  • Layer caching speeds up rebuilds only if earlier layers haven't changed
  • Multi-stage builds separate build-time deps from runtime, cutting image size by up to 90%
  • Slim base images (Alpine, distroless) reduce attack surface and pull time
  • Biggest mistake: installing build tools in the final image — they're never needed at runtime
  • Another overlooked win: use a .dockerignore file to exclude local caches and secrets from the build context
  • Use docker image history --no-trunc to see the size and command of each layer
  • Track compressed size, not uncompressed — pull time depends on the former
  • Set a size budget per service and enforce it in CI to prevent silent bloat creep
✦ Definition~90s read
What is Optimising Docker Images?

At its heart, image optimisation is about understanding Docker's union file system and using it deliberately. Each Dockerfile instruction adds a new layer. The image's total compressed size isn't just the sum of final files — it includes everything that was written in earlier layers, even if later instructions delete or overwrite them.

Imagine you're packing a suitcase for a weekend trip.

That's the trap most beginner Dockerfiles fall into: they install compilers, download Maven, compile the app, remove the compiler — but the compiler's bytes still live in an older layer, never to be recovered.

The real measure isn't the size you see when you run docker images — that's the uncompressed size. Pull time is based on compressed size, and registry storage costs are typically based on compressed size as well. So optimisation targets both. A 2GB uncompressed image might compress to 700MB, still far too large for a microservice that does nothing but serve HTTP.

Optimisation isn't a one-time thing. It's a discipline: layer ordering, multi-stage builds, base image selection, and cache management. Get it right and your deploys are faster, your registry bill drops, and your attack surface shrinks. Get it wrong and you're paying for every unnecessary megabyte, every day.

One more thing: compressed vs uncompressed matters. That 2GB image may compress to 700MB on push, but when pulled over a 100Mbps link, that's still 56 seconds of network time. Every megabyte has a cost — even if it's not obvious from docker images.

Here's a nuance most guides miss: layer deduplication across images. If you have ten microservices all built on debian:stable-slim, each pulls the same base layer once on a node. But if each uses a different apt-get install in the first RUN layer, those layers aren't shared. That's why keeping common dependencies in a shared base image saves both build time and node storage.

Plain-English First

Imagine you're packing a suitcase for a weekend trip. A beginner throws in every piece of clothing they own — just in case. An experienced traveller packs only what they'll actually wear. Docker image optimisation is exactly that: ruthlessly removing everything your app doesn't need at runtime so the 'suitcase' is as light as possible. A 2 GB image and a 50 MB image can run identical apps — the difference is just whether you packed wisely.

Every second your CI pipeline spends pushing a bloated Docker image to a registry is a second your deployment is blocked. At scale — dozens of services, hundreds of deploys per day — a 1 GB image versus a 100 MB image isn't a minor aesthetic preference, it's the difference between a 30-second deploy and a 5-minute one. It compounds across your entire fleet, inflates egress costs on AWS/GCP/Azure, and widens your attack surface because every unused package is a potential CVE waiting to be exploited.

The root cause is almost always the same: Dockerfiles written like shell scripts — one giant RUN block, a fat base image chosen for convenience, build tools left behind after compilation, secrets accidentally baked into layers. Docker's union filesystem means every layer is permanent history; you can't 'delete' a file from a previous layer by removing it in a later one — the bytes are still there, just hidden. And that's the dirty secret of Docker bloat: the bytes you think you deleted are still there, costing you on every pull.

By the end of this article you'll be able to diagnose a bloated image using real tooling, rewrite Dockerfiles using multi-stage builds and layer-cache discipline, choose the right minimal base image for your workload, and avoid the production gotchas that catch even experienced engineers off guard. We'll go deep on the internals — because understanding why Docker layers work the way they do is what separates a developer who memorises tricks from one who can solve novel problems. Understanding layers isn't trivia — it's the difference between cutting 80% of image size and cutting 0%.

Here's the hard truth: most teams don't realise how much bloat costs them until their registry bill hits four figures. One team we consulted had a 2.3GB image for a simple Go webserver. After applying the techniques in this article, they dropped it to 12MB. That's not optimisation — that's elimination.

What Is Optimising Docker Images?

At its heart, image optimisation is about understanding Docker's union file system and using it deliberately. Each Dockerfile instruction adds a new layer. The image's total compressed size isn't just the sum of final files — it includes everything that was written in earlier layers, even if later instructions delete or overwrite them. That's the trap most beginner Dockerfiles fall into: they install compilers, download Maven, compile the app, remove the compiler — but the compiler's bytes still live in an older layer, never to be recovered.

The real measure isn't the size you see when you run docker images — that's the uncompressed size. Pull time is based on compressed size, and registry storage costs are typically based on compressed size as well. So optimisation targets both. A 2GB uncompressed image might compress to 700MB, still far too large for a microservice that does nothing but serve HTTP.

Optimisation isn't a one-time thing. It's a discipline: layer ordering, multi-stage builds, base image selection, and cache management. Get it right and your deploys are faster, your registry bill drops, and your attack surface shrinks. Get it wrong and you're paying for every unnecessary megabyte, every day.

One more thing: compressed vs uncompressed matters. That 2GB image may compress to 700MB on push, but when pulled over a 100Mbps link, that's still 56 seconds of network time. Every megabyte has a cost — even if it's not obvious from docker images.

Here's a nuance most guides miss: layer deduplication across images. If you have ten microservices all built on debian:stable-slim, each pulls the same base layer once on a node. But if each uses a different apt-get install in the first RUN layer, those layers aren't shared. That's why keeping common dependencies in a shared base image saves both build time and node storage.

Dockerfile.simple-bloatedDOCKERFILE
1
2
3
4
5
6
7
8
9
10
# A naive Dockerfile that wastes space
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y build-essential curl wget
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get install -y nodejs
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
Forge Tip
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
Production Insight
A bloated image doesn't just waste storage — it adds minutes to every deploy and hides security vulnerabilities in unused packages.
The first step to optimisation is visibility: use dive to inspect each layer's filesystem.
Another reality check: a 2 GB image that's 90% unused packages is a ticking time bomb for PCI or SOC2 audits.
But also: the compressed size matters for CI pull times, not just storage — a 2GB uncompressed image may compress to 700MB, but that's still 700MB every pull.
Pro tip: track compressed size, not uncompressed. Get it from docker manifest inspect or regctl.
Case in point: one fintech team's 2.3GB image compressed to 850MB, taking 10 minutes to pull on shared CI runners. After optimization, 12MB compressed image pulled in under 5 seconds.
Key Takeaway
Every Docker image is a stack of layers. The goal is to minimise the total size by reducing both the number of layers and the weight of each layer.
Optimisation starts with measurement.
If you can't measure it, you can't shrink it.
Always use dive before pushing to a registry.
When Should You Care About Image Size?
IfImage > 500 MB for a simple service
UsePrioritise optimisation immediately. Run dive and apply multi-stage builds.
IfImage ~100 MB or less
UseMonitor for regressions but focus on other performance metrics first.
Docker Image Optimization Pipeline THECODEFORGE.IO Docker Image Optimization Pipeline From bloated Java image to lean production deploy Multi-Stage Builds Compile in one stage, copy artifacts to final Choose Base Image Alpine, Slim, or Distroless for minimal size Layer Cache Optimization Order layers by change frequency for CI/CD .dockerignore Best Practices Exclude dev files, reduce build context Production Monitoring Track image size over time to catch bloat ⚠ Using fat base images like Ubuntu for Java apps Switch to Alpine or Distroless to cut size by 80% THECODEFORGE.IO
thecodeforge.io
Docker Image Optimization Pipeline
Optimising Docker Images

Understanding Docker Layers and the Union Filesystem

Docker images are built from a series of read-only layers. Each instruction in a Dockerfile (FROM, RUN, COPY) creates a new layer. The union filesystem overlay2 stacks these layers and presents them as a single filesystem. This is why deleting a file in a later layer doesn't reduce image size — the file still exists in an underlying layer.

Understanding this mechanism is the key to writing efficient Dockerfiles. Every layer is cached and reused as long as the instruction text and its context (e.g., the files being copied) haven't changed. But misplaced order of instructions can invalidate the entire cache.

The point: place instructions that change infrequently (like installing packages) early, and instructions that change with every code change (like COPY . /app) as late as possible.

But there's a deeper trap: RUN rm -rf /var/cache/apt in a separate instruction doesn't remove those files from the previous layer. The files are still there in the layer stack, just hidden. That's why you must combine apt-get install and apt-get clean in the same RUN instruction using shell operators. Every byte you clean inside the same layer is actually gone. Every byte you clean in a later layer is still costing you.

Here's a real-world number: a single RUN with apt-get install -y build-essential && apt-get clean saves about 30MB compared to splitting it into two RUN commands. That 30MB per layer adds up fast when you have 5-10 layers.

Want a mental model? Each layer is like a delta snapshot in Git. If you add a file in commit A and delete it in commit B, the blob still exists in the object store. Docker is the same — docker history shows every layer's bytes.

Dockerfile.layer-orderDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
# Good layer order: stable deps first, code last
FROM node:18-alpine
WORKDIR /app

# Install dependencies (changes rarely)
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Copy application code (changes frequently) — invalidates cache only from here
COPY . .

CMD ["node", "server.js"]
Mental Model: Layer Stack
  • Each Dockerfile instruction adds a new read-only layer on top of the previous ones.
  • If you install a package in one layer and remove it in the next, the package still exists in the lower layer — the image stays large.
  • Use multi-stage builds to copy only the final artifact into a fresh, clean layer stack.
  • Combine cleanup commands into the same RUN instruction to avoid wasting bytes.
Production Insight
Placing frequently changing instructions early (like COPY .) kills caching and forces all downstream layers to rebuild.
Always structure your Dockerfile with stable instructions first (APK/APT installs, dependency downloads), then code copy at the end.
Pro tip: use BuildKit's --cache-from to reuse layers from previous builds — it's a game changer for CI pipelines.
Consider layer squashing for Lambda deployment packages — single-layer images pull faster.
But beware: squashing layers with --squash is experimental and can cause cache misses.
One more: if you rebuild regularly, structure your Dockerfile so that the layer that changes most often (the code copy) is at the very end. That way you reuse all previous layers from the cache.
Key Takeaway
Layer order determines cache effectiveness.
Put stable instructions first, code at the end.
Never delete files in a later layer — use multi-stage to avoid carrying them at all.
Use docker build --squash sparingly — it breaks caching.
Should You Squash Layers?
IfYou need a single-layer image for minimal size (e.g., for AWS Lambda or distroless configs)
UseUse docker build --squash (experimental) or multi-stage to copy only the final files into a fresh base.
IfYou want to preserve cacheability and share common base layers across services
UseDo not squash. Keep layers separate to reuse cached base layers in CI pipelines.

Multi-Stage Builds: The Right Way to Compile and Package

The single most effective technique to reduce image size is multi-stage builds. Instead of using one Dockerfile that compiles your application and then runs it — leaving all build tools, source code, and intermediate artifacts in the final image — you split the process into two or more stages.

Stage 1 (build stage): Use a full SDK base image, install all build dependencies, compile your application. Stage 2 (runtime stage): Use a minimal base image (e.g., distroless, Alpine slim, or JRE-slim) that contains only the runtime necessary to execute your compiled artifact. Then copy only the compiled output (e.g., JAR, binary) from the build stage.

The syntax uses FROM ... AS alias, and COPY --from=alias to grab files from an earlier stage.

This pattern eliminates build-time dependencies, reduces image size dramatically, and also improves security because the final image contains only what's needed at runtime.

One pattern that catches people out: copying the entire /build directory instead of just the artifact. If your static files are in /build/static but you also have node_modules in /build, they'll all come along. Be precise with your COPY paths. For Go apps, copy only the single binary. For Java, copy only the *.jar. For Python, you might need to copy the entire site-packages, but you can control it with a virtualenv.

A common gotcha: the COPY --from stage still adds a layer. Combine multiple COPY --from calls? Not possible — each COPY adds a layer, but you can't merge them. Accept the overhead — it's still far smaller than including the build stage.

Real example: a Java team at a fintech used multi-stage and dropped their image from 1.8GB to 145MB. The build stage contained Maven, JDK, and all source; the runtime stage had only the JRE and the fat JAR. Their deploy time dropped from 8 minutes to 45 seconds.

Dockerfile.multistageDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Stage 1: Build
FROM maven:3.8.4-openjdk-11-slim AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline  # cache deps
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM openjdk:11-jre-slim
WORKDIR /app
COPY --from=builder /build/target/myapp.jar ./app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
Common Pitfall: Copying Too Much from Builder
Don't copy the entire /build directory from the builder stage. That includes source code, compiled test classes, and intermediate artifacts. Always copy only the exact file (JAR, binary) that your runtime needs.
Production Insight
Multi-stage builds can reduce image size by 85-95% compared to single-stage builds.
But if you forget to use --link with COPY (BuildKit), copied files create new layers that don't share the base layer — defeating layer deduplication.
Another trap: copying the whole /build directory instead of the specific artifact — you'll drag in test jars, cached .class files, even source code.
Be extra precise: copy COPY --from=builder /build/target/*.jar to exclude everything else.
For Go static binaries, copy a single file — no need to include any runtime libs.
Pro tip: for multi-language apps (e.g., frontend built with Node, backend with Go), use three stages: one for frontend build, one for backend build, and a final runtime stage that copies outputs from both.
Key Takeaway
Use multi-stage builds for every compiled language.
Separate build environment from runtime environment.
Copy only the final artifact — nothing else.
Use COPY --from=builder /app/target/*.jar to be precise.
When to Use Multi-Stage?
IfYour app is compiled (Java, Go, C++, Rust) or has build-time dependencies (npm install, pip install with compilation)
UseAlways use multi-stage builds. The final image should contain zero build tools.
IfYour app is a scripting language with no build step (e.g., pure Python script with standard library)
UseMulti-stage is optional but still recommended to avoid including source code and to use a minimal base.

Choosing the Right Base Image: Alpine, Slim, Distroless

The base image you choose sets the lower bound for your final image size and directly influences your attack surface. The trade-off is between size, package availability, and compatibility.

  • Full images (e.g.
  • ubuntu:latest, node:latest) are huge (600MB+). They contain a full OS with utilities, compilers, and often unnecessary libraries. Avoid them.
  • Slim variants (e.g.
  • node:18-slim, openjdk:11-jre-slim) strip out documentation, locales, and package manager caches. Typically 50-80% smaller than full images. Good for most apps that require standard glibc.
  • Alpine (e.g.
  • node:18-alpine) is based on musl libc. Very small (~5MB base) but can cause compatibility issues with binaries compiled against glibc. Works well for Go, Rust, and interpreted languages.
  • Distroless (e.g., gcr.io/distroless/java17-debian11) contains only the runtime and the application — no shell, no package manager, no utilities. Minimal attack surface. Best for security-sensitive production workloads.

A real-world nuance: Distroless images from Google are based on Debian and use glibc, so they avoid the Alpine compatibility trap. But they lack a shell, so you can't exec into them for debugging. You'll need to set up sidecar debug containers or use kubectl debug with ephemeral containers. Many teams start with slim Debian and only go distroless after their security audit demands it.

Performance impact: pulling a full ubuntu:latest over 100Mbps takes ~6 seconds; pulling alpine:3.18 takes ~0.5 seconds. That 5.5 seconds per pull across 50 nodes is an extra 275 seconds of pod startup time — per deploy.

Dockerfile.base-examplesDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Distroless for security-critical apps
FROM gcr.io/distroless/java17-debian11
COPY --from=builder /app/target/*.jar /app.jar
CMD ["/app.jar"]

# Slim for general use
FROM node:18-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]

# Alpine for static binaries
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
CMD ["/server"]
Production Tip
Always pin a specific version tag (or even a digest) for your base image. Using :latest can cause builds to break when the maintainer updates the image.
Production Insight
Switching from ubuntu:22.04 to ubuntu:22.04-slim can reduce image size by 30-40% without any code changes.
But if you need glibc and pick Alpine, you'll face runtime errors. Test thoroughly before switching base images in production.
Also consider distroless if your security team demands zero package managers — but you'll lose the ability to shell into the container for debugging.
Test base image switches in staging for at least a week to catch compatibility issues.
A common trap: FROM node:18-alpine works fine until you add a native npm module that needs glibc — then it silently fails.
Pro tip: use ldd on your binary inside the container to check what it really requires before choosing the base.
Key Takeaway
Smallest isn't always best. Match the base image's libc and library set to your application's requirements.
Pin versions, test, and scan for CVEs before deploying.
When in doubt, start with a slim Debian-based image — it covers 90% of use cases with minimal risk.
When in doubt, start with -slim Debian.
Which Base Image Should You Choose?
IfApp is compiled with GCC/glibc (e.g., Java, C++, Python with native extensions)
UseUse slim variant of Debian or Ubuntu (e.g., -slim). Avoid Alpine unless you add libc6-compat and test.
IfApp is compiled with musl (Go static binary, Rust), or is a scripting language with pure dependencies
UseAlpine is a great choice. Small and fast.
IfSecurity is paramount and you don't need a shell or package manager at runtime
UseUse distroless images from Google.

Distroless vs Alpine Comparison Table

When you've narrowed your base image choices to Alpine and distroless, the decision often comes down to a trade-off between size, compatibility, and debuggability. The table below breaks down the key differences across the dimensions that matter in production.

DimensionAlpine (musl)Distroless (glibc)
Base size~5 MB~20-50 MB (depends on language)
CompatibilityMay break with dynamically linked glibc binaries; use libc6-compatFull glibc compatibility — works with almost everything
Shell accessYes (ash)No — no shell, no package manager
Package managerapkNone
DebuggingCan exec in with /bin/shMust use kubectl debug or debug sidecars
CVE densityLow base, but adding packages increases CVEsVery low — only runtime libs
Build speedFast (small downloads)Moderate (larger base, but stable)
Best forStatic binaries (Go, Rust), scripting without native depsJava, Python with native extensions, security-audited workloads

The most common mistake teams make is assuming Alpine is always the right choice because it's smallest. In reality, the 5 MB savings over distroless is negligible when your final image is already 100+ MB. The glibc compatibility of distroless often saves more time in debugging than the size saves in pull time.

Consider this rule of thumb: if your application or any of its dependencies use apt, yum, or precompiled binaries that expect glibc, choose distroless. If you control the entire dependency chain and can compile statically, Alpine is a valid option. But even then, distroless offers better security with no shell.

A common compromise: use a slim glibc-based image during development for ease of debugging, and switch to distroless in production builds to minimise attack surface.

Dockerfile.distroless-comparisonDOCKERFILE
1
2
3
4
5
6
7
8
9
10
# Distroless runtime (recommended for production)
FROM gcr.io/distroless/java17-debian11 AS runtime
COPY --from=builder /app/target/app.jar /app.jar
CMD ["/app.jar"]

# Alpine alternative (only if fully static or musl-compatible)
FROM alpine:3.18
RUN apk add --no-cache openjdk17-jre
COPY --from=builder /app/target/app.jar /app.jar
CMD ["/usr/bin/java", "-jar", "/app.jar"]
Which to Pick for Your CI?
If your CI environment is consistent (e.g., all same architecture), distroless typically yields fewer surprises. Alpine can introduce hard-to-debug no such file errors when a binary expects glibc.
Production Insight
The 15 MB difference between Alpine and distroless is irrelevant compared to the 2+ GB of bloat most teams start with. Focus on getting the right libc compatibility first, then worry about the last few megabytes.
In production, distroless images eliminate the need for package updates and reduce the number of security patches required — but they force you to adopt debug container workflows.
If your security auditor asks about 'unnecessary binaries in the container', distroless is the answer.
Test your application on both base images in a staging environment with realistic traffic before committing to one.
One more angle: Alpine's musl libc can cause issues with some Go programs that rely on cgo and glibc. Always verify with integration tests.
Key Takeaway
Choose distroless for production when you need glibc compatibility and minimal attack surface. Choose Alpine only when your application is fully static or fully musl-compatible — the 15 MB difference rarely justifies the risk.
Distroless vs Alpine: Decision Guide
IfApplication uses glibc (e.g., Java, Python with numpy, C++), security audit strict
UseUse distroless (glibc-based). Accept larger base size for compatibility and low CVEs.
IfApplication statically linked (Go, Rust), or pure scripting with no native deps
UseAlpine is fine. But consider distroless if you want to eliminate shell and package manager entirely.

Layer Cache Optimisation for CI/CD

In a CI pipeline, image rebuilds happen multiple times a day. A well-structured Dockerfile can reuse cached layers from previous builds, cutting build time from minutes to seconds.

The rule: order instructions by frequency of change. Start with system packages (almost never change), then language dependencies (change when you update dependencies), then application code (changes every commit).

Also, use .dockerignore to avoid sending unnecessary files (like .git, node_modules, target) to the Docker daemon — they invalidate the COPY layer.

BuildKit (enabled by default in recent Docker versions) offers additional cache optimization: --cache-from to use remote caches, --mount=type=cache for persistent package caches across builds.

But there's a hidden cost: cache invalidation can be unpredictable. If your CI runner doesn't reuse the Docker cache between builds (e.g., ephemeral runners), you lose all the benefit of layer ordering. In that case, lean on BuildKit's --cache-from pointing to the previous build in your registry. That's the pattern that reduces 5-minute builds to 30 seconds.

Another trick: for monorepos with multiple Dockerfiles, share a common base layer by building a base image containing all system deps, then use FROM base in each service Dockerfile. This saves both build time and registry storage.

One thing that trips up teams: cache mounts (--mount=type=cache) persist on the host. If you're running on ephemeral CI runners like GitHub Actions hosted, they don't persist between runs. You need either a persistent cache volume or --cache-from with a registry.

Pro tip: use --cache-to and --cache-from together to push cache to a registry and pull it on the next build. This works even across different runners.

Dockerfile.cache-optimisedDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Optimised Dockerfile for CI: stable → less stable → code
FROM python:3.11-slim AS base
WORKDIR /app

# Layer 1: System deps (rarely changes)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev gcc \
    && rm -rf /var/lib/apt/lists/*

# Layer 2: Python deps (changes with requirements.txt)
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Layer 3: Application code (changes every commit)
COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
CI Cache Strategy
Use Docker BuildKit's --cache-from to pull a previous build's cache from a registry. This speeds up remote builds dramatically.
Production Insight
A poorly ordered Dockerfile can cause every CI build to take 5+ minutes because package installation is repeated on every commit.
Reorder your instructions once and save hundreds of hours across the team annually.
Don't forget .dockerignore — one missing line there can invalidate the COPY layer on every build, killing your cache strategy.
Use BuildKit's --cache-from for remote CI runners — it's the key to consistent cache reuse.
If you use ephemeral runners, consider using a remote Docker cache in a S3-compatible bucket.
Pro tip: if you have a monorepo, build a shared base image containing all common system dependencies. Then each service's Dockerfile starts with FROM shared-base:latest — that one layer is cached across all services, saving both build time and disk space on CI.
Key Takeaway
Layer caching is the single biggest lever for reducing CI build time.
Put instructions that change least often first.
Always include a .dockerignore.
And enable BuildKit for advanced cache features.
Pin base image digests to avoid surprise cache invalidations from updated tags.
Should You Use BuildKit Cache Mounts?
IfYour CI pipeline rebuilds images frequently and package downloads are the bottleneck
UseAdd --mount=type=cache for your package manager (npm, pip, apt). It persists downloads across builds without bloating the image.
IfYou need to ensure a clean build every time (e.g., security-sensitive environments)
UseAvoid cache mounts. Use fresh downloads each build to guarantee correct dependencies.

.dockerignore Best Practices and Template

The .dockerignore file is the simplest, most overlooked tool for keeping images lean. It tells Docker which files and directories to exclude from the build context — preventing secrets, local caches, and unnecessary files from being sent to the Docker daemon. Without a .dockerignore, every COPY . instruction will include your entire project directory, including .git (sometimes hundreds of MB), node_modules, target, .venv, and IDE configuration files.

A typical .dockerignore should at minimum exclude
  • .git/ — avoids leaking repository history
  • node_modules/, vendor/, __pycache__/ — local dependency directories
  • target/, build/, dist/ (unless you copy them explicitly) — build artifacts
  • .env, *.pem, credentials.json — secrets and credentials
  • .gitignore, .dockerignore, Dockerfile* — build context files not needed in the image
  • *.md, LICENSE — documentation files
  • test/, tests/, spec/ — test code that's not needed at runtime
  • CI/CD config files (.github/, .gitlab-ci.yml, Jenkinsfile)

But be careful: .dockerignore patterns are relative to the build context. Trailing slashes matter: /node_modules/ ignores only at root, while node_modules/ ignores anywhere. Use **/node_modules to catch nested directories if needed.

One common mistake: excluding target/ in a Java project but then realizing the JAR is built into target/. The solution: use fine-grained COPY instructions rather than broad COPY . .. Better: copy only the specific output folder in a multi-stage build.

Here is a production-ready template that works for most language ecosystems:

.dockerignoreIGNORE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# Dependencies
node_modules
.bundler
vendor/bundle
.venv
__pycache__
*.pyc

# Build artifacts
target
build
dist
*.jar
!target/*.jar   # Preserve the final JAR if copying from context

# Git
.git
.gitignore
.gitattributes

# CI/CD
.github
.gitlab-ci.yml
Jenkinsfile
.circleci

# IDE
.idea
.vscode
*.swp
*.swo

# Secrets / credentials
.env
*.pem
credentials.json
*.key

# OS files
.DS_Store
Thumbs.db

# Docker
Dockerfile*
.dockerignore

# Documentation
*.md
LICENSE

# Logs
*.log
npm-debug.log*
Output
// This template excludes common bloat. Customize based on your project structure.
Don't Exclude Your Build Output Unintentionally
If you rely on COPY . . to get compiled artifacts into the image, make sure your .dockerignore doesn't exclude those files. The pattern target would block target/my-app.jar — use a more specific ignore or switch to multi-stage builds.
Production Insight
A missing .dockerignore is the #1 cause of accidental secret leaking in Docker images. I've seen production .env files baked into layers because the developer forgot to add it.
The second most common issue: excluding the very files you need. A Java project that ignores target/ but then has COPY . . in the Dockerfile will get an empty build — the JAR is missing.
Solution: never use COPY . . in a production Dockerfile — always copy specific folders.
Also remember that .dockerignore only affects the build context sent to the daemon, not the files inside the image. It's a pre-filter, not a layer cleaner.
Use docker build --no-cache . to force a fresh context send and verify your ignores.
Pro tip: commit the .dockerignore file and review it as part of the code review process. It's as important as the Dockerfile itself.
Key Takeaway
Always include a .dockerignore in every project to prevent secrets and unnecessary files from inflating the build context and image layers.
Should You Use .dockerignore in Every Project?
IfAny project with a Dockerfile
UseAbsolutely yes. Even a minimal .dockerignore excluding .git and node_modules prevents hundreds of MB from entering the build context.
IfProject with no secrets or large directories?
UseStill use it to exclude .git and IDE files. It's a safety net.

Production Monitoring: Tracking Image Size Over Time

Image size tends to creep up over time as developers add new dependencies, install debugging tools for troubleshooting, or forget to clean up temporary files. Monitoring image size as a CI metric helps catch bloat before it reaches production.

You can integrate tools like docker scout or dive into your CI pipeline to fail builds if image size exceeds a threshold. Also, use docker image history to track the impact of each Dockerfile change.

Another approach: maintain a Dockerfile.sizelimit or use external tools like Regctl to query registry manifests and track image size across tags.

One team we worked with added a simple CI step that compares new image size against the previous tag and fails if it increased by more than 5%. That single check caught three regressions in the first month, each caused by a developer adding a debugging library they forgot to remove.

Pro tip: store size metrics in a time-series database (e.g., InfluxDB) and graph them on a dashboard. A weekly trend that shows +2% every week means you'll hit your budget in 25 weeks — but you'll only notice when the deploy fails.

I've also seen teams use GitHub Actions to post a comment on every PR comparing the new image size to the base branch. That transparency alone stops bloat — nobody wants to see 'Image increased by 45%' on their PR.

ci-size-check.shBASH
1
2
3
4
5
6
7
8
# Check image size in CI (example with Docker Scout)
SIZE=$(docker scout quickview myapp:latest | grep "Total compressed size" | awk '{print $(NF-1)}' || echo 0)
MAX_SIZE_MB=200
if (( $(echo "$SIZE > $MAX_SIZE_MB" | bc -l) )); then
  echo "Image size $SIZE MB exceeds limit $MAX_SIZE_MB MB"
  exit 1
fi
echo "Image size OK: $SIZE MB"
Historical Size Tracking
Use regctl image digest and regctl image manifest to pull image sizes from the registry without pulling the whole image. Perfect for CI checks.
Production Insight
Teams often ignore image size until the registry bill shocks them. Set a hard limit in CI and alert when it's exceeded.
Track image size per tag over time — it's a leading indicator of dependency bloat.
Pro tip: use docker scout to also track the number of CVEs per image — it correlates with size.
Consider a dashboard showing image size trend per service over time, with alerts for >10% weekly increase.
Automate the measurement — manual checks don't scale.
One more: if you use AWS ECR, you can get billed for compressed size in the registry. A 200MB image at $0.10/GB/month is trivial per image, but 100 images add up. And pull costs (egress) are where the real money goes.
Key Takeaway
Treat image size as a performance metric.
Monitor it in CI, set thresholds, and fail builds that exceed them.
Use tools like dive and docker scout for deep inspection.
Set a size budget for each service and enforce it.
Should You Monitor Image Size in CI?
IfYou have a registry bill or deploy frequently
UseYes, add a size gate to CI. Fail the build if compressed size exceed threshold.
IfYour images are already very small and stable
UseStill monitor for regressions, but you can set a more lenient threshold (e.g., warn only).

Security Implications of Bloated Images

Every unnecessary package in a Docker image is a potential entry point for attackers. A fat base image like ubuntu:latest includes thousands of binaries, many of which have known CVEs. Even if your app doesn't use them, they're still in the container and exploitable if an attacker gains access.

Distroless images eliminate this surface entirely — no shell, no package manager, no utilities. But they also make debugging harder (you can't exec into the container). A compromised distroless image is harder to exploit because the attacker lacks basic tools like curl, wget, or bash.

Another angle: multi-stage builds reduce the attack surface by leaving build tools (e.g., compilers, debuggers) in the builder stage. The final image only contains what's needed to run the app.

Consider this real example: a team had a Node.js image with curl, wget, vim, and netcat installed. An attacker who got a shell via a vulnerable Express route had immediate internet access and lateral movement tools. Switching to distroless for the final image removed all those utilities — the attacker's shell, even if they got one, would have no curl, no wget, no shell history. It's a massive reduction in blast radius.

CVE density: a typical ubuntu:22.04 image has ~200 CVEs at baseline. Switch to distroless and that drops to ~5. Which would you rather deploy to production?

But here's the trade-off: distroless images can't run apt-get update to patch CVEs. You have to rebuild the image with a new base. That's fine for CI but means you can't hotfix a running container. Plan your patch cycle accordingly.

cve-scan.shBASH
1
2
3
4
# Quick CVE scan with Docker Scout
docker scout cves myapp:latest --format sarif > cve-report.json
# Or use dive for layer-level CVE analysis
dive --ci --highestUserWorst myapp:latest
Production Security Tip
Run docker scout cves <image> on every image you push to production. If your base image has 200+ CVEs, switch to a slim or distroless variant immediately.
Production Insight
A bloated image with curl and wget installed is a 3-second attack vector after an initial compromise.
Switching to distroless removes these tools but requires changing your debugging workflows — use ephemeral debug containers instead.
Bottom line: every megabyte of unnecessary software is a liability.
Run docker scout cves as a mandatory CI step to catch vulnerable base images early.
Even if you can't go distroless, removing a single package like vim can reduce CVE count by 10%.
Pro tip: also scan your base image separately — you'd be surprised how many CVEs come from a base image you don't even update.
Key Takeaway
Image size is directly linked to attack surface.
Use distroless for security-critical workloads.
Scan every image for CVEs before deployment.
Don't sacrifice security for debugging convenience — invest in proper debugging tools.
Remove all unnecessary utilities from final images.
Should You Use Distroless?
IfYour security audit requires minimal attack surface and you have debug container workflows (ephemeral containers, sidecars)
UseUse distroless. It eliminates shell and package managers, drastically reducing CVEs.
IfYour team relies on exec-ing into containers for debugging
UseStart with a slim Debian image. Plan to migrate to distroless by implementing ephemeral debug containers first.

Advanced Layer Caching with BuildKit

Docker's BuildKit (enabled by default since Docker 23.0) offers several cache optimization features that go beyond simple layer ordering.

  • --cache-from: Pull a previous build's cache from a registry. Essential for remote CI runners where local cache doesn't persist.
  • --mount=type=cache: Persist package manager caches (like apt, npm, pip) across builds without including them in the final image. Reduces network downloads dramatically.
  • COPY --link: Copies files without creating a new layer that depends on the previous layer. Improves cache sharing between build stages.
  • Cache mounts: Mount a scratch directory for build artifacts like .m2 for Maven or node_modules for npm, which are kept across builds but not in the final image.

Using these features requires minimal Dockerfile changes but yields significant speedups in CI.

But there's a subtlety with cache mounts: they persist across builds on the same host. If you're using ephemeral CI runners (like GitHub Actions hosted runners), cache mounts give no benefit because the runner's filesystem is fresh each time. In that scenario, only --cache-from with a remote registry works. Know your CI environment before investing in one pattern over the other.

A real-world benchmark: a Node.js app with npm ci took 45 seconds per build without cache mounts. Adding --mount=type=cache,target=/root/.npm dropped that to 8 seconds on the second build — a 5.6x improvement. On a hundred builds per day, that's 62 minutes saved.

Pro tip: use --cache-to and --cache-from together to push cache to a registry and pull it on the next build. This works even across different runners.

Dockerfile.buildkit-cacheDOCKERFILE
1
2
3
4
5
6
7
8
9
10
# syntax=docker/dockerfile:1.4
FROM node:18-alpine
WORKDIR /app

# Cache npm cache across builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

COPY . .
CMD ["node", "server.js"]
BuildKit Syntax Hint
Add # syntax=docker/dockerfile:1.4 at the top of your Dockerfile to enable the latest BuildKit features. Without it, --mount and --link may not work.
Production Insight
Using BuildKit cache mounts can reduce npm install time from 45 seconds to 5 seconds on repeated CI builds.
But beware: cache mounts persist across builds, so you might accidentally reuse a broken cache. Clear it periodically with docker builder prune --filter type=exec.cachemount.
Also, --cache-from is most effective when you tag intermediate images; otherwise, the cache is lost after the registry push.
Avoid cache mounts on ephemeral CI runners; they provide no benefit.
For monorepos, use --mount=type=cache carefully — it can cross-contaminate packages across projects.
Pro tip: use --cache-to and --cache-from together for a full cache life cycle. Push cache to a registry on successful builds, pull it on the next run.
Key Takeaway
BuildKit's cache mounts and remote cache are force multipliers for CI speed.
Enable the latest Dockerfile syntax (1.4+).
Use --cache-from with a remote registry for persistent caching in CI pipelines.
Clear cache mounts periodically to avoid corruption.
Use COPY --link to improve layer cache sharing between stages.
Should You Use Cache Mounts in Your CI?
IfYour CI runners are long-lived or use persistent volumes
UseAdd --mount=type=cache for your package manager. It will drastically reduce download times on subsequent builds.
IfYour CI runners are ephemeral (e.g., GitHub Actions hosted, GitLab shared runners)
UseCache mounts won't persist. Use --cache-from with a registry instead.

Docker Image Linting (Hadolint)

Just as you lint your source code, you should lint your Dockerfiles. Hadolint is a battle-tested Dockerfile linter that checks for common mistakes, security issues, and inefficiencies that lead to bloated images. It can be integrated into your CI pipeline to catch problems before they become production incidents.

Hadolint parses the Dockerfile using a Docker parser and applies a set of rules. Each rule is prefixed with a code like DL3000 to DL4006. Some of the most useful rules for image optimisation include:

  • DL3008: Pin versions in apt-get install. This prevents surprise upgrades that double image size.
  • DL3009: Delete apt-get files after install. Enforces the && rm -rf /var/lib/apt/lists/* pattern.
  • DL3018: Pin versions in apk add for Alpine.
  • DL3020: Use COPY instead of ADD for copying files. ADD has extra features that can unintentionally add bloat.
  • DL3025: Use arguments JSON form for CMD and ENTRYPOINT to avoid shell overhead.
  • DL3033: Specify version with pip install (Python).
  • DL3042: Avoid cache directories with npm cache clean --force.
  • DL3044: Do not upgrade packages alone (RUN apt-get upgrade) — often pulls in unnecessary bloat.

Running Hadolint locally is trivial, but the real value is in CI. You can fail the build if any errors are found, or only warnings depending on your strictness. Here's how to integrate with GitHub Actions:

.github/workflows/hadolint.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: Dockerfile Lint
on: [push]
jobs:
  hadolint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hadolint/hadolint-action@v3.1.0
        with:
          dockerfile: Dockerfile
          failure-threshold: error
          format: json
          output-file: hadolint-report.json
      - uses: actions/upload-artifact@v4
        with:
          name: hadolint-report
          path: hadolint-report.json
Output
// Fails the build if any error-level violations are found. Warnings are allowed.
Start with a Relaxed Configuration
If you introduce Hadolint to an existing project, it may generate hundreds of violations. Start with failure-threshold: info and fix the most impactful rules first (DL3008, DL3009, DL3020). Tighten over time.
Production Insight
Hadolint won't catch every bloat issue — it can't analyse multi-stage copy precision — but it catches low-hanging fruit like missing package version pins and unremoved apt caches.
Integrate Hadolint into your CI pipeline as a mandatory step for all Dockerfile changes. It's like a spellchecker for images.
One team we worked with reduced average image size by 15% just by fixing the top 5 Hadolint warnings across their microservices.
Pro tip: customize Hadolint's severity levels with a .hadolint.yaml config file to focus on rules that matter most for your stack.
Remember: linting is a safety net, not a substitute for understanding layers.
Key Takeaway
Use Hadolint to automatically catch common Dockerfile inefficiencies and security issues before they bloat your images.
Should You Lint Dockerfiles?
IfAny project with Dockerfiles
UseYes, integrate Hadolint into CI. It catches low-hanging bloat and security issues for free.
IfExisting project with many Dockerfiles
UseStart with a lenient config (warnings only) and fix the top 10 rules first. Tighten over time.

Image Size Governance: Setting Budgets and Automating Checks

Image size is a performance and cost metric that deserves the same attention as latency or error rates. Without a budget, bloat creeps in silently. The fix: set per-service size limits and enforce them in CI.

Start by establishing a baseline: measure the current compressed size of every image using docker scout quickview or docker images --format. Then set a reduction target (e.g., 20% smaller) as the initial budget. Store budgets in a YAML file committed to your repository.

In CI, use a script that builds the image, extracts its compressed size, compares it against the budget, and fails if exceeded. Integrate with regctl to query historical sizes from the registry without pulling the entire image.

For advanced governance, enforce that any PR that increases image size by more than 10% requires a review. Use Docker Scout policies to automatically scan for excessive CVEs or size regressions.

One team we know saved $3000/month in ECR costs just by adding a size gate. The gate caught two instances where a developer accidentally added a 200MB debug image into their base. Without the gate, that cost would have run indefinitely.

Another approach: use docker manifest inspect in a cron job to check sizes of images in the registry and alert if any exceed the budget. That catches bloat that slipped through CI (e.g., if someone pushed manually).

image-size-check.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# Image size governance check for CI
# Reads budget from docker-image-budgets.yaml

IMAGE="myapp:latest"
BUDGET_FILE="docker-image-budgets.yaml"

# Get compressed size from registry (without pulling)
COMPRESSED_SIZE=$(regctl image manifest $IMAGE --format '{{.Size}}' | awk '{printf "%.0f", $1 / 1048576}')
ALLOWED=$(yq eval ".budgets[] | select(.image=="$IMAGE") | .max_mb" $BUDGET_FILE)

if [ "$COMPRESSED_SIZE" -gt "$ALLOWED" ]; then
  echo "Image $IMAGE is $COMPRESSED_SIZE MB, budget is $ALLOWED MB — FAIL"
  exit 1
fi
echo "Image $IMAGE size OK: $COMPRESSED_SIZE MB"
Set a Realistic Baseline First
Don't set an arbitrary budget without understanding your current state. Measure all images for a week, then set the budget to 80% of the average. Tighten over time.
Production Insight
Teams that enforce size budgets in CI catch regressions before they reach production.
One team saw a 40% size creep over three months simply because nobody was watching.
Automated enforcement forces developers to think about every new dependency.
Cost savings: a 200MB reduction per image for 10 services deployed 50 times/day saves ~$1500/month in egress alone.
Integrate size checks with Slack alerts so the team sees bloat immediately.
Don't just check at build time — also run nightly scans on registry images for drift.
Pro tip: use a size budget as a CI gate, but also log historical data to a dashboard for trend analysis.
Key Takeaway
Image size is a metric, not a preference.
Set a budget based on current baselines.
Enforce it in CI — fail builds that exceed the limit.
Monitor trends over time to catch gradual bloat.
If you don't measure it, bloat creeps in.
Use regctl for fast registry queries without pulling images.
Per-Service vs Global Budgets?
IfServices have wildly different needs (e.g., ML model vs Go binary)
UseUse per-service budgets defined in a YAML file tracked in version control. Each service has a unique limit.
IfAll services are similar (e.g., microservices with comparable stacks)
UseSet a global limit that applies to all. Deviations indicate an outlier that needs review.

Practical Refactoring Workflow: From Bloated to Lean Dockerfile

Let's walk through a real-world refactoring. Start with a typical bloated Dockerfile:

`` FROM ubuntu:latest RUN apt-get update RUN apt-get install -y curl wget vim git build-essential RUN apt-get install -y python3 python3-pip RUN curl -sSL https://sh.rustup.rs -o rustup.sh RUN sh rustup.sh -y RUN git clone https://github.com/someapp WORKDIR /someapp RUN cargo build --release RUN cp target/release/someapp /usr/local/bin/ RUN rm -rf /someapp ~/.cargo CMD ["someapp"] ``

Problems: No multi-stage, fat base, unnecessary packages (vim, git), cleanup in separate RUN layers, no .dockerignore, using :latest.

Stage 1 (builder): FROM rust:1.65-slim AS builder — install only necessary build deps, compile. Stage 2 (runtime): FROM ubuntu:22.04-slim — only runtime libs if needed, otherwise use FROM scratch and copy static binary. Add .dockerignore: .git, target, *.md Pin base image digests Combine apt-get install and clean in one RUN.

After refactor: 1.2GB → 12MB for a Go/Rust static binary, or ~50MB if using glibc runtime. That's a 95-99% reduction.

Make this process a standard checklist in your team's Dockerfile review template. After a few months, it becomes second nature.

I've seen teams automate this refactoring with a script that runs dive, identifies top layers by size, and suggests a multi-stage pattern. You don't need to do it manually every time — build a tool once.

Dockerfile.refactoredDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
# Refactored with multi-stage and minimal base
# Stage 1: Build
FROM rust:1.65-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN cargo fetch  # cache deps
COPY src ./src
RUN cargo build --release

# Stage 2: Runtime (using distroless for security)
FROM gcr.io/distroless/cc   # or 'scratch' for fully static
COPY --from=builder /app/target/release/someapp /usr/local/bin/someapp
CMD ["/usr/local/bin/someapp"]
Refactoring Checklist
1. Identify if app is compiled (need multi-stage) or interpreted (can go alpine/distroless). 2. Remove all build tools from final image. 3. Combine cleanup into same RUN layer. 4. Pin base image digests. 5. Add .dockerignore. 6. Test thoroughly.
Production Insight
The refactoring workflow above cuts image size by 95-99% in most cases.
Automate the identification of bloat using dive and docker scout in CI — suggest minimal base images automatically.
The biggest blocker is team habits: make the refactoring checklist part of every Dockerfile review.
Pro tip: run the checklist as a CI job that outputs a lint report with suggestions.
Track 'image size per service' over time to celebrate wins and identify regressions.
One more: if you have a lot of images to refactor, prioritize those with the highest pull frequency. A 500MB image pulled 100 times a day is costing more than a 2GB image pulled once a week.
Key Takeaway
Refactoring a bloated Dockerfile follows a repeatable pattern: multi-stage, minimal base, pinned digests, .dockerignore, combined RUN cleanup.
Apply this checklist to every Dockerfile review.
The result is a 90-99% reduction in image size.
When to Refactor vs Rebuild from Scratch?
IfDockerfile is old, has many layers, and no multi-stage
UseRefactor using the checklist. It's faster than rewriting from scratch because you keep the build logic.
IfThe build process is fundamentally broken or uses an unsupported base
UseRebuild from scratch with a modern, minimal base image and multi-stage structure.

Minimum Layers: The Silently Bloated Build

Every RUN, COPY, ADD in your Dockerfile is a layer. Layers aren't cheap cache bonuses - they're permanent overhead in your final image. The misconception is that dividing install steps into pretty isolated RUN commands is clean code. It's not. It's bloat.

Consolidate. RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* — one layer, not three. Use && chains, not line breaks that look neat in a diff but double your image size. Every orphaned /var/cache/apt/archives from a split RUN is dead weight your production host will pull across the wire.

Why this matters: layer count inflates push/pull time. A 10-layer image vs 30-layer image for the same executable — the latter pulls slower because the registry and Docker daemon negotiate more layers. There's no free lunch. The union filesystem doesn't compress them away.

BadVsGoodLayerConsolidation.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — devops tutorial

// BAD: three layers for one install
FROM debian:12-slim
RUN apt-get update
RUN apt-get install -y curl jq
RUN rm -rf /var/lib/apt/lists/*

// GOOD: one layer, zero waste
FROM debian:12-slim
RUN apt-get update && \
    apt-get install -y curl jq && \
    rm -rf /var/lib/apt/lists/*
Output
Good: Image size = 125MB
Bad: Image size = 187MB
Layers: 3 vs 1 (identical runtime)
Production Trap:
Don't assume --no-install-recommends inherits across layers. It doesn't. Each RUN starts fresh. Add it to every install command.
Key Takeaway
One logical operation = one RUN statement. Chain your commands. Every split layer is dead weight you pay for on every pull.

Docker Build Arguments: Your Secret Weapon for Conditional Bloat

You're not shipping one image for all environments, right? Right? Three identical Node.js images with dev tools, test certs, and debug endpoints baked into production. That's not 'agile.' That's a security incident waiting to be exploited.

ARG and --build-arg let you conditionally omit entire dependency trees during build time. No multi-stage gymnastics needed. Define ARG NODE_ENV=production in your Dockerfile. Use COPY and RUN only when NODE_ENV != production. Example: only install dev dependencies when building the test image.

Why this kills two birds: smaller prod image and tighter blast radius. If someone dumps the container, they don't get Jest, Mocha, or your package-lock.json pointing at internal npm registries. That's one less lateral movement path they can take after the initial breach.

ConditionalBuildArgs.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — devops tutorial

FROM node:20-slim AS base
ARG NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN if [ "$NODE_ENV" = "production" ]; then \
      npm ci --only=production; \
    else \
      npm install; \
    fi
COPY . .

// Build commands:
// docker build --build-arg NODE_ENV=development -t app:dev .
// docker build --build-arg NODE_ENV=production -t app:prod .
Output
Production image: 245MB (no devDeps, no .env.example)
Development image: 385MB (full node_modules with 180MB of test runners)
Senior Shortcut:
Always cache your .env files with ARGs, not ENVs. ARGs are build-time only. ENVs persist into the runtime layer. Production containers leak DB_PASSWORD if you use ENV. Use ARG + build-time substitution instead.
Key Takeaway
ARGs buy you environment-optimized images from one Dockerfile. Prod gets no dev tools, no test keys, no debug endpoints.

Update Base Images: The Silent Drift That Doubles Your Image

You locked your base image to alpine:3.18.0 last year. Cute. That image is now storing 12 months of stale packages, security patches you never pulled, and lazy maintainer cruft. Meanwhile, the latest alpine:3.20 stripped an entire compiler toolchain from its default layer. Your image is 40MB heavier than necessary.

docker pull alpine:3.18.0 vs docker pull alpine:3.20 — same OS, 60MB vs 45MB. That 15MB is the difference between a base image that was built with developer convenience (GCC, make, perl) vs one built for containers: minimal.

Don't pin to ancient minors. Use docker build --pull to force fresh base layers. Automate a weekly job that rebuilds your images and checks size drift. If your base image maintainers drop bloat in a patch, you inherit it silently. The only defense is active, scheduled rebuilds with alerting on size deltas.

Your security team will thank you. So will your SRE who has to pull 200 replicas during a failover.

BaseImageDriftTracker.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — devops tutorial

// Cron job (GitHub Actions scheduled workflow)
name: Base Image Rebuild Monitor
on:
  schedule:
    - cron: '0 6 * * 0'  # weekly

jobs:
  rebuild-and-verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with fresh base
        run: |
          docker build --pull -t prod-app:latest .
          docker image history prod-app:latest
          docker images | grep 'prod-app'
Output
Old base (alpine:3.18.0):
SIZE: 62.4MB
RUN: 23 layers
New base (alpine:3.20.1):
SIZE: 47.1MB
RUN: 17 layers
Delta: -15.3MB, -6 layers
Production Trap:
If your base image maintainer deprecates a minor and removes all tags (yes, they do this), docker build --pull will fail. Always have a fallback: pin to at least a major version like alpine:3 not alpine:3.18.0.
Key Takeaway
Stale base images rot silently. --pull on every build, weekly rebuilds, and alert on size deltas. Your images get lighter over time, not heavier.

Introduction: Why Image Size Matters Beyond Storage Costs

Docker images are the atomic unit of deployment in modern cloud‑native stacks, yet most teams accidentally ship images that are 5×–10× larger than needed. Bloated images don’t just waste bandwidth—they increase cold‑start latency, expand the attack surface by including unnecessary packages, and silently degrade CI/CD pipeline throughput. Optimising Docker images is therefore not a cosmetic exercise but a core DevOps discipline that directly impacts deployment speed, security posture, and infrastructure cost. This guide focuses on the highest‑leverage technique—multi‑stage builds—which alone can shrink an image from gigabytes to a hundred megabytes. After mastering multi‑stage builds, you’ll see how every other optimisation (layers, ignore files, linting) reinforces the same principle: ship only what the runtime needs.

intro-why-size-matters.ymlYAML
1
2
3
4
5
6
// io.thecodeforge — devops tutorial
# Bloat indicators: every 100 MB adds ~1.5s pull time
# Source: empiric tests, 2024
# Target: image under 150 MB for web services
# Check current size:
# docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}"
Output
nginx:latest 187 MB
# After optimisation target: 40–60 MB
Production Trap:
A 2 GB image that ‘works locally’ will break your production deployment budget. Always measure before you start optimising—make size visible in your CI pipeline.
Key Takeaway
Image optimisation is a security and velocity concern, not just a storage one. Always start by measuring the current image size.

1. Adopt Multi‑Stage Builds

Multi‑stage builds use multiple FROM statements in a single Dockerfile to separate build dependencies from runtime artifacts. The build stage can include compilers, headers, and package managers (gcc, npm, pip) that are discarded in the final stage—only the compiled binary or production dependencies are copied over. For example, a Node.js app with devDependencies of 300 MB shrinks to under 100 MB by running npm ci --production in the final stage. Similarly, Go binaries built with CGO_ENABLED=0 produce a statically linked binary that runs on a scratch base (0 MB base image). The pattern: one stage for “build everything”, one stage for “ship the result”. Set --target if you need debugging intermediate stages locally. Always pin base image digests in each stage to avoid subtle version mismatches.

multi-stage-go.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — devops tutorial
# Go multi-stage: 1.2 GB12 MB after strip
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/myapp

# Runtime stage (scratch or distroless)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/myapp /myapp
EXPOSE 8080
CMD ["/myapp"]
Output
IMAGE ID SIZE
<sha> 12.4 MB
Production Trap:
Never copy a full /usr/lib from the build stage. You’ll carry shared libraries and defeat the multi-stage shrink. Copy only the binary and required config files.
Key Takeaway
One Dockerfile, two stages: build with tools, ship only the artifact. This single technique eliminates 80% of image bloat.
● Production incidentPOST-MORTEMseverity: high

How a 1.2GB Java Image Took Down Our Friday Deploy

Symptom
CI builds taking 8+ minutes, 'docker pull' timing out on AWS EKS nodes, registry costs skyrocketing.
Assumption
We thought 'it's just a few MB of extra libraries — no big deal'.
Root cause
The Dockerfile had 7 RUN instructions, each adding layers. One RUN installed Maven and all its dependencies, another left the Maven local repository (.m2) in the image. The base image was openjdk:11-jdk (400MB) instead of a slim JRE. No multi-stage build was used.
Fix
Switched to a multi-stage build: stage 1 used maven:3.8.4-openjdk-11-slim to compile, stage 2 used openjdk:11-jre-slim and copied only the JAR. Added '--link' to COPY to reduce layer count. Used .dockerignore to exclude local .m2. Final image size: 118MB.
Key lesson
  • Use multi-stage builds for any compiled language — separate build tools from runtime.
  • Choose the smallest base image that provides the runtime your app needs (JRE, not JDK).
  • Clean up package manager caches inside the same RUN layer (e.g., apt-get clean).
  • Profile every image with dive or docker scout before pushing to a registry.
  • Pin base image digests, not just tags — a tag change can silently double your image size.
Production debug guideSymptom → Action guide for common image size problems8 entries
Symptom · 01
Image >500MB for a simple web app
Fix
Run dive <image> to see per-layer size. Look for layers adding big files like /usr/share/doc, /var/cache/apt, or entire language SDKs.
Symptom · 02
CI build time increases without code changes
Fix
Check if a dependency version changed or a base image tag moved (e.g., :latest). Pin exact base image digests in your Dockerfile.
Symptom · 03
Container fails with 'exec format error' or missing libs
Fix
You likely switched to a minimal base (Alpine) but need glibc. Use gcr.io/distroless/java17-debian11 or add apk add libc6-compat for Alpine.
Symptom · 04
Security scanner reports high CVEs in image
Fix
Scan with docker scout cves <image>. Replace fat base with distroless or a hardened slim image. Remove unnecessary packages, especially curl, wget, and vim.
Symptom · 05
Container not starting with 'exec user process caused: no such file or directory'
Fix
Your binary was compiled for the wrong architecture (e.g., x86_64 vs arm64). Use file <binary> inside the container to check. Rebuild with the correct base image for your target platform.
Symptom · 06
Image size creeps up 5% every month with no code change
Fix
Check if a base image tag is floating. Pin to a digest. Also inspect if your CI is pulling a new builder image each time — cache invalidation can add layers.
Symptom · 07
Image size suddenly doubled after base image tag update
Fix
Probably a floating tag (:latest) was updated to a larger version. Pin to a specific digest. Use docker pull <image>@sha256:... to lock the exact base.
Symptom · 08
Old layers accumulate even after cleaning up
Fix
Use docker image prune and docker builder prune to clean up dangling layers. In CI, use docker build --prune to avoid left over layers from previous builds.
★ Quick Debug Commands for Docker Image SizeRun these commands when you suspect an image is bloated. No theory — just the commands that find the fat layers.
Image seems too large
Immediate action
Inspect layer sizes and files changed per layer.
Commands
dive <image>
docker image history --no-trunc <image>
Fix now
Identify the largest layer, then refactor that RUN instruction or split into multi-stage. Use docker build --no-cache to discard stale layers.
CI builds are slow because image push takes minutes+
Immediate action
Check if your image has unnecessary layers or if base image is fat.
Commands
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker scout recommendations <image>
Fix now
Switch to a distroless or Alpine slim base. Use multi-stage builds. Squash layers if needed (experimental: --squash).
Container gets 'not found' errors for shared libraries+
Immediate action
Check if you used a minimal base that lacks required libs.
Commands
ldd /app/your-binary (or use `docker run --entrypoint ldd <image> /app/binary`)
docker run --entrypoint sh <image> -c 'ls -la /lib/x86_64-linux-gnu/'
Fix now
Use a base image that includes the necessary libs (e.g., debian:stable-slim instead of alpine if your app needs glibc). Or add apk add libc6-compat for Alpine.
Security scan reports 200+ CVEs+
Immediate action
Identify which layers introduce the most CVEs.
Commands
docker scout cves <image> --format sarif
docker scout recommendations <image>
Fix now
Switch to a more minimal base (distroless or slim). Remove packages like curl, vim, wget. Pin base image digests.
How to check compressed size without pulling the entire image+
Immediate action
Use registry tools to get size from manifest.
Commands
docker manifest inspect <image> or regctl image manifest <image> --format '{{.Size}}'
docker scout quickview <image> | grep 'compressed'
Fix now
Include compressed size check in CI. Fail the build if compressed size exceeds a threshold (e.g., 200MB).
Base Image Comparison by Size and Use Case
DimensionFull (e.g., ubuntu:latest)Slim (e.g., node:18-slim)Alpine (e.g., alpine:3.18)Distroless (gcr.io/distroless/...)
Base size600-800 MB100-200 MB5-10 MB20-50 MB
Libcglibcglibcmuslglibc
Shell accessYes (bash)Yes (bash)Yes (ash)No
Package manageraptaptapkNone
CVE density~200+ CVEs~50-100 CVEs~10-30 CVEs~5-10 CVEs
Best forLegacy appsGeneral productionStatic binariesSecurity-critical production
Pull time (100Mbps)~6s~1.5s~0.5s~0.8s

Common mistakes to avoid

4 patterns
×

Using depends_on without a healthcheck

Symptom
API crashes on startup with ECONNREFUSED because the database container started but is not yet ready to accept connections.
Fix
Add a healthcheck block to the database service using pg_isready, then use condition: service_healthy in the API depends_on block.
×

Installing build tools in the final stage when using single-stage Dockerfile

Symptom
Image size is >1GB even for a small app because compilers, headers, and package managers are left in the image.
Fix
Switch to multi-stage builds. Keep build tools only in the builder stage. Copy only the final artifact into a minimal runtime base.
×

Using :latest tag for base images

Symptom
Image size suddenly doubles overnight because the base image maintainer updated the tag to a larger version.
Fix
Pin all base images to a specific digest (e.g., FROM node:18@sha256:abc123). Use Renovate or Dependabot to keep them up-to-date.
×

Not combining RUN apt-get install and apt-get clean in the same layer

Symptom
Image contains hundreds of MB of cached package lists and .deb files in /var/cache/apt, even though apt-get clean is called later.
Fix
Always chain commands: RUN apt-get update && apt-get install -y <pkg> && rm -rf /var/lib/apt/lists/*
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how Docker layers work and why deleting a file in a later layer ...
Q02SENIOR
Walk me through debugging a Docker image that's 2GB but should be under ...
Q03SENIOR
When would you choose Alpine over Distroless, and vice versa?
Q04SENIOR
How can you enforce image size limits across your team's CI/CD pipelines...
Q05SENIOR
What is the most common cause of Docker image bloat in Java applications...
Q01 of 05SENIOR

Explain how Docker layers work and why deleting a file in a later layer doesn't reduce image size.

ANSWER
Docker images are composed of read-only layers, each corresponding to a Dockerfile instruction. The union filesystem (overlay2) stacks these layers, and the final filesystem is the merged view. When you delete a file in a later layer, a whiteout entry is created in that layer, hiding the file from view. However, the original file still exists in an underlying layer and occupies storage. This is why image size doesn't decrease: the bytes are still there, just hidden. To truly remove files, avoid ever writing them in earlier layers, or use multi-stage builds to copy only necessary artifacts into a fresh final layer.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why does my image size not shrink when I delete files in a later RUN instruction?
02
What's the difference between `docker image history` and `dive`?
03
Should I use --squash in production?
04
How often should I rebuild my base images to patch CVEs?
05
Can I reduce image size without changing base image?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Docker. Mark it forged?

21 min read · try the examples if you haven't

Previous
Docker Swarm Basics
14 / 18 · Docker
Next
Docker Networking Deep Dive