
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Default Kotlin Docker images often exceed 800MB because they bundle a full JDK, Gradle caches, and OS-level tooling that production workloads never use. To effectively shrink Kotlin Docker images, you must decouple the build environment from the runtime artifact using multi-stage builds and custom Java runtimes. This guide provides the exact Dockerfile patterns, JLink configurations, and base image selections I use to deliver sub-150MB containers that pass SOC 2 compliance audits and deploy faster on constrained Kubernetes clusters.
jlink, and copies only the fat JAR and minimal runtime into a distroless or Alpine final stage. This reduces image size from ~800MB to under 150MB while maintaining security.How do you configure multi-stage builds to shrink Kotlin Docker images?
Multi-stage builds are the foundation of any strategy to reduce Docker image size with multi-stage builds. For Kotlin applications, the key is separating the heavy Gradle compilation phase from the lean runtime execution phase. A common mistake is copying the entire build context into the final image or using a single-stage Dockerfile that retains compiler toolchains, test frameworks, and dependency caches.
Optimized Multi-Stage Dockerfile for Kotlin
This Dockerfile uses Gradle’s wrapper, leverages layer caching for dependencies, and produces a production-ready artifact. Note the explicit separation of concerns between stages:
# Stage 1: Build and package
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon
COPY src ./src
RUN ./gradlew bootJar --no-daemon --stacktrace
# Stage 2: Generate custom JRE with jlink
FROM eclipse-temurin:21-jdk-alpine AS jre-builder
RUN jlink \
--add-modules java.base,java.logging,java.net.http,java.sql,jdk.crypto.ec \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=zip-9 \
--output /custom-jre
# Stage 3: Minimal runtime
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=jre-builder /custom-jre /opt/jre
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_HOME=/opt/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
USER nonroot
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] The dependency download step is isolated so code changes don’t invalidate the cached layer. The --no-daemon flag prevents Gradle from spawning background processes that can cause hangs in containerized builds. Always pin your base image tags to specific versions rather than latest to ensure reproducible builds across CI runs and team members.
How does JLink create custom JREs for smaller Kotlin containers?
JLink is the single most impactful tool when you need to shrink Kotlin Docker images beyond what multi-stage builds alone achieve. Bundled with the JDK since version 9, it analyzes your application’s actual module dependencies and generates a custom Java runtime containing only those modules. For a typical Kotlin Spring Boot API, this often eliminates 100–200MB of unused JDK components like GUI libraries, compiler internals, and legacy APIs.
Identifying Required Modules
Before running JLink, determine which modules your Kotlin application actually needs. Use jdeps on your compiled fat JAR:
jdeps --print-module-deps --ignore-missing-deps build/libs/app.jar This outputs a comma-separated list like java.base,java.logging,java.net.http,java.sql,jdk.crypto.ec. Add jdk.crypto.ec explicitly if your app makes HTTPS calls — it’s frequently missed by automated analysis but required at runtime for TLS handshakes. Test thoroughly in staging; missing modules manifest as NoClassDefFoundError or UnsupportedOperationException at runtime, not build time.
JLink Flags Explained
- --strip-debug: Removes debug symbols from native libraries. Safe for production; saves 15–30MB.
- --no-man-pages and --no-header-files: Excludes documentation and C headers irrelevant in containers.
- --compress=zip-9: Maximum compression for the generated runtime. Slower build, smaller output.
- --add-modules: Explicitly list required modules. Never use
ALL-MODULE-PATH; it defeats the purpose.
In my experience auditing container images for Kubernetes resource limits and requests, teams that skip JLink consistently over-provision memory because the base JDK inflates RSS. A custom JRE gives the JVM less metadata to load, reducing both image size and startup memory footprint.
Which base image should you choose: Alpine, Distroless, or UBI Micro?
The base image selection determines your security posture, debugging capability, and final size. There is no universal best choice; the right answer depends on your operational requirements and compliance constraints. Here’s how they compare in practice for Kotlin workloads:
| Criteria | Alpine (eclipse-temurin-alpine) | Distroless (gcr.io/distroless) | UBI Micro (redhat/ubi9-micro) |
|---|---|---|---|
| Final Size (with JLink) | ~130-160MB | ~110-140MB | ~140-170MB |
| Shell / Debugging | Yes (ash/sh) | No | No |
| Package Manager | apk | None | microdnf (limited) |
| CVE Surface Area | Moderate (musl libc) | Minimal | Minimal (RHEL-hardened) |
| Compatibility | musl vs glibc issues possible | glibc, well-tested | glibc, enterprise-certified |
| Compliance Fit | SOC 2 with scanning | SOC 2 / ISO 27001 preferred | Enterprise / Regulated industries |
| Non-root Default | Manual setup required | Built-in (:nonroot tag) | Built-in (ubi-micro-nonroot) |
For most Kotlin microservices targeting container image scanning with Trivy and SOC 2 compliance, I default to Distroless. It removes the shell entirely, eliminating an entire attack vector class. If your team needs to exec into containers for debugging during development, use Alpine in dev/staging and Distroless in production via build arguments. UBI Micro is the right call when your organization has Red Hat support contracts or operates in regulated sectors requiring certified base images.
What Gradle and JVM optimizations further reduce Kotlin image size?
Image size isn’t only about the Dockerfile. Your build configuration and JVM runtime flags directly impact the final artifact and operational efficiency. These optimizations complement the structural reductions from multi-stage builds and JLink.
Gradle Build Optimizations
- Enable configuration cache: Add
org.gradle.configuration-cache=truetogradle.properties. Speeds up CI builds significantly, reducing pipeline minutes even if it doesn’t shrink the final image directly. - Use Shadow plugin wisely: Configure
shadowJarto exclude META-INF signatures, README files, and unused service loader configs. These add kilobytes that accumulate. - Kotlin daemon in containers: Always pass
--no-daemonto Gradle in Docker. The daemon persists between builds in local dev but causes memory bloat and zombie processes in ephemeral CI containers. - Exclude test fixtures: Ensure
bootJardoesn’t include test classes or resources. Verify withjar tf build/libs/app.jar | grep -i test.
JVM Runtime Flags for Containers
When you shrink Kotlin Docker images, you must also tune the JVM to respect container boundaries. Without these flags, the JVM may misread cgroup limits and OOMKill unexpectedly:
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:InitialRAMPercentage=50.0", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"] UseContainerSupport is enabled by default in JDK 10+ but worth being explicit. MaxRAMPercentage=75.0 leaves headroom for native memory, metaspace, and thread stacks. Setting ExitOnOutOfMemoryError ensures the container crashes fast instead of limping in a degraded state — critical for Kubernetes health checks and autoscaling. For deeper guidance on tuning JVM behavior in orchestrated environments, review the four golden signals of monitoring to align your runtime metrics with observable SLOs.
How do you verify and maintain optimized Kotlin Docker images in CI?
Optimization without verification is guesswork. Integrate size checks and security scanning into your CI pipeline to prevent regression. A 20MB creep per release compounds quickly; catch it before merge.
CI Pipeline Size Gate
# In GitHub Actions or GitLab CI
docker build -t app:candidate .
SIZE=$(docker image inspect app:candidate --format='{{.Size}}')
THRESHOLD=157286400 # 150MB in bytes
if [ "$SIZE" -gt "$THRESHOLD" ]; then
echo "Image size $SIZE exceeds 150MB threshold"
exit 1
fi Pair this with Trivy or Grype scanning in the same pipeline stage. Fail the build on critical CVEs in the base image or dependencies. For teams managing secrets management with HashiCorp Vault, ensure your optimized image doesn’t accidentally embed secrets via COPY instructions or environment variables baked into layers.
Ongoing Maintenance Checklist
- Re-run jdeps quarterly: Dependency updates may introduce new module requirements. Automate this check in CI.
- Pin all base image digests: Tags mutate; SHA256 digests don’t. Use
FROM gcr.io/distroless/java21-debian12@sha256:...for reproducibility. - Monitor cold start times: Smaller images pull faster, but JLink’d runtimes can have slightly different classloading characteristics. Track p99 startup latency as an SLO.
- Test non-root execution: Distroless :nonroot runs as UID 65532. Verify file permissions, volume mounts, and network bindings work without root.
Shipping Leaner Kotlin Containers Reliably
To shrink Kotlin Docker images sustainably, combine multi-stage builds, JLink-generated custom JREs, and distroless base images as your default production pattern. Measure everything: set CI size gates, scan for vulnerabilities on every build, and track runtime performance metrics post-deployment. The goal isn’t just a smaller number — it’s a secure, compliant, operationally predictable artifact that your team can ship confidently. If your current Kotlin containers exceed 200MB or fail security scans, reach out to discuss a container optimization audit tailored to your stack and compliance requirements.