Back to Docker: Images, Containers, Multi-Stage
Q4Question 4 of 10

Optimizing a Docker Image's Size

Key Concepts
  • Start with the smallest base image that runs your app: slim JRE, Alpine, or distroless.
  • Use multi-stage builds so build tools and caches are discarded from the final image.
  • Add a .dockerignore to exclude .git, IDE files, local caches, and secrets from the build context.
  • Clean up package-manager caches in the same RUN instruction that installs packages.
  • Order instructions so dependencies are resolved before source is copied for better cache hits.
  • Smaller images pull faster, start faster, and have a smaller attack surface.

Definition

Shrink an image by picking a minimal base, building in multiple stages, keeping layers clean, and using .dockerignore so only the app and its runtime ship.
  • Minimal base – use a slim JRE, Alpine, or distroless image instead of a full OS + JDK.
  • Multi-stage build – compile in a heavy toolchain stage and copy only the artifact into the slim runtime stage.
  • Layer hygiene – install packages and clean caches in the same RUN instruction so transient files are never committed.
  • .dockerignore – exclude .git, IDE files, local build output, and secrets from the build context.
  • Instruction ordering – copy dependency manifests before source code so a code change does not invalidate the cached dependency layer.

Think of packing a courier parcel from the National Bank of India to a branch. Use the smallest box (minimal base), pack only the finished documents, not the printing equipment (multi-stage), do not throw in junk mail and sticky notes (.dockerignore), flatten packing so there are no air pockets (fewer layers), and remove bubble wrap before sealing (clean caches in the same step).

Understanding the Concepts

Why does image size matter?

Smaller images pull faster, start faster, cost less to store, and present a smaller attack surface. In banking, faster pulls mean faster scaling during peak trading hours, and fewer tools in the image mean fewer things for an attacker to abuse.

BASH
# Compare image sizes
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
# REPOSITORY                  TAG       SIZE
# transfer-service-bloated    latest    1.24GB
# transfer-service-optimized  latest    247MB

How does the base image affect size?

The base image is usually the single biggest contributor. Choose the smallest base that runs your application.

DOCKERFILE
# Option 1: Full JDK (700+ MB)
FROM eclipse-temurin:21-jdk

# Option 2: Slim JRE (200 MB)
FROM eclipse-temurin:21-jre

# Option 3: Alpine JRE (150 MB)
FROM eclipse-temurin:21-jre-alpine

# Option 4: Distroless (130 MB, no shell or package manager)
FROM gcr.io/distroless/java21-debian12

| Base | Size | Shell | Package Manager | Debugging |
|---|---|---|---|---|
| Full JDK | 700+ MB | Yes | Yes | Easy |
| Slim JRE | ~200 MB | Yes | Yes | Easy |
| Alpine JRE | ~150 MB | Yes (ash) | apk | Moderate |
| Distroless | ~130 MB | No | No | Hard |

How does multi-stage remove build tools?

Multi-stage discards the build toolchain from the final image.

DOCKERFILE
# Build stage: heavy, discarded
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:resolve
COPY src/ src/
RUN mvn clean package -DskipTests

# Runtime stage: slim, shipped
FROM eclipse-temurin:21-jre
COPY --from=build /app/target/transfer-service.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

How does .dockerignore reduce size?

The build context is everything sent to the Docker daemon. Without .dockerignore, a broad COPY . . drags in unnecessary files.

TEXT
# .dockerignore
.git
.gitignore
*.md
README*
.idea/
*.iml
target/
node_modules/
.env
.env.*
secrets.properties
__tests__/
BASH
# Without .dockerignore: build context 150 MB (includes .git)
# With .dockerignore: build context 2 MB (only what's needed)
Sending build context to Docker daemon  2.1MB

Why must you clean caches in the same RUN instruction?

A file created in one layer and deleted in a later layer still occupies space in the earlier layer. The deletion does not shrink the image.

DOCKERFILE
# WRONG: cache stays in the install layer.
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*   # Does NOT shrink the image!

# CORRECT: install and clean in one RUN.
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*
# The cache is never committed to a layer.

How does instruction ordering improve caching?

Copy dependency manifests before source code. A code change then only invalidates the cheap top layers.

DOCKERFILE
# WRONG: any code change invalidates the dependency layer.
COPY . /app
RUN mvn package   # Re-downloads all dependencies on every code change.

# CORRECT: dependencies cached separately.
COPY pom.xml /app/
RUN mvn dependency:resolve   # Only re-runs if pom.xml changes.
COPY src/ /app/src/
RUN mvn package              # Only this re-runs on code changes.

What are common mistakes?

DOCKERFILE
# WRONG: using COPY . . without .dockerignore.
# Copies .git (100 MB), node_modules, IDE files, secrets.
COPY . /app

# CORRECT: add .dockerignore and copy only what's needed.
COPY pom.xml /app/
COPY src/ /app/src/
DOCKERFILE
# WRONG: using the full JDK as the runtime base.
FROM maven:3.9-eclipse-temurin-21
# The final image carries Maven and javac, never needed at runtime.

# CORRECT: use the slim JRE.
FROM eclipse-temurin:21-jre

jlink builds a custom trimmed Java runtime with only the modules your app uses. GraalVM native-image removes the JVM entirely.

DOCKERFILE
# jlink: custom JRE with only needed modules
FROM eclipse-temurin:21-jdk AS jre-build
RUN jlink --add-modules java.base,java.sql,java.logging \
    --strip-debug --no-man-pages --output /custom-jre

FROM debian:bookworm-slim
COPY --from=jre-build /custom-jre /opt/java
COPY --from=build /app/target/transfer-service.jar /app.jar
ENTRYPOINT ["/opt/java/bin/java", "-jar", "/app.jar"]
# Custom JRE: ~60 MB

What should a senior engineer know?

  • Distroless removes the shell, so kubectl exec for debugging is not available. This is a trade-off, not a universal win.
  • Alpine uses musl libc, which can cause subtle issues with some native libraries (e.g., gRPC). Test thoroughly.
  • Smaller images are not only cheaper and faster; they reduce the attack surface and make vulnerability scans cleaner.
  • Pin base images by digest for provenance in banking: FROM eclipse-temurin:21-jre@sha256:abc...
BASH
# Check what's inside the final image
docker history registry.bank.local/transfer-service:1.2.0 --no-trunc
# Every layer, its size, and its command are visible.

Visual Overview

DIAGRAM
  Size reduction techniques:

  ┌───────────────────────────────────────────┐
  │  1. Minimal base: slim JRE or distroless   │  -500 MB
  │  2. Multi-stage: discard build tools       │  -400 MB
  │  3. .dockerignore: exclude .git, IDE, etc. │  -100 MB
  │  4. Clean caches in same RUN               │  -50 MB
  │  5. jlink custom JRE (optional)            │  -120 MB
  └───────────────────────────────────────────┘

  Before: 1.2 GB  (full JDK, Maven, source, .git)
  After:  250 MB  (slim JRE + app.jar)
  Extreme: 60 MB  (custom jlink JRE + app.jar)

  Instruction ordering for cache:
  ┌──────────────┐
  │ FROM base    │  ← Rarely changes
  │ COPY pom.xml │  ← Changes on dep update
  │ RUN mvn dep  │  ← Cached unless pom changes
  │ COPY src/    │  ← Changes on every commit
  │ RUN mvn pkg  │  ← Re-runs only on code change
  └──────────────┘

Key Takeaway

Optimizing a Docker image is about systematically removing everything the running application does not need. Start with the smallest base that runs the app: a slim JRE instead of a full JDK, or distroless for the strongest security posture. Use multi-stage builds so the compiler, build tool, and caches stay in a discarded stage. Add a .dockerignore so the build context never includes .git, IDE files, or secrets.

Layer hygiene matters because a file created in one layer and deleted in a later one still takes up space. Install packages and clean caches in the same RUN instruction so the cache is never committed. Order instructions so dependencies are resolved before source is copied, so a code change only rebuilds the cheap top layers.

The benefits compound: smaller images pull faster, start faster, cost less to store, and present a smaller attack surface with cleaner vulnerability scans. For extreme reduction, combine multi-stage with jlink to ship a custom trimmed Java runtime.

Remember one sentence: pick the smallest base, build in stages, add .dockerignore, clean in the same RUN, and ship only what the app actually needs to run.

---

Share
Use ← → arrow keys to navigate