Definition
- 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.
# 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.
# 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.
# 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.
# .dockerignore
.git
.gitignore
*.md
README*
.idea/
*.iml
target/
node_modules/
.env
.env.*
secrets.properties
__tests__/
# 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.
# 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.
# 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?
# 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/
# 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
How can you go further with jlink and native images?
jlink builds a custom trimmed Java runtime with only the modules your app uses. GraalVM native-image removes the JVM entirely.
# 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 execfor 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...
# 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
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.
---