Shrink Kotlin Docker Images

Khimananda Oli 9 min read Programming and Languages
Shrink Kotlin Docker Images

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.

Build Stageeclipse-temurin:21-jdk-alpine• Gradle + Kotlin Compiler• Dependencies Cache• Unit Tests RunDISCARDED after buildJLink OptimizationCustom JRE Generation• Module Analysis• Strip Debug Symbols• Remove Unused Modules~60MB Custom RuntimeRuntime Stagegcr.io/distroless/java21-debian12• Custom JRE Only• Application Fat JAR• No Shell / Package MgrFINAL: ~120-150MB
Multi-stage build architecture to shrink Kotlin Docker images: build tools and caches are discarded, leaving only the optimized JRE and application binary in the final runtime stage.

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.

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.

  • --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.

Full JDK (~350MB)java.desktop (GUI/AWT)jdk.compiler (javac internals)jdk.javadoc, jdk.jshelljava.xml.crypto, jdk.dynalinkjava.base ✓java.net.http, java.sql ✓jdk.crypto.ec ✓jlink--strip-debug--compress=zip-9Custom JRE (~60MB)java.basejava.logging, java.net.httpjava.sql, jdk.crypto.ec~80% Size ReductionFinal ImageCustom JRE + App JAR~120-150MB TotalNo shell, no pkg manager
JLink prunes unused JDK modules to produce a custom JRE, dramatically reducing the footprint when you shrink Kotlin Docker images for production deployment.

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:

CriteriaAlpine (eclipse-temurin-alpine)Distroless (gcr.io/distroless)UBI Micro (redhat/ubi9-micro)
Final Size (with JLink)~130-160MB~110-140MB~140-170MB
Shell / DebuggingYes (ash/sh)NoNo
Package ManagerapkNonemicrodnf (limited)
CVE Surface AreaModerate (musl libc)MinimalMinimal (RHEL-hardened)
Compatibilitymusl vs glibc issues possibleglibc, well-testedglibc, enterprise-certified
Compliance FitSOC 2 with scanningSOC 2 / ISO 27001 preferredEnterprise / Regulated industries
Non-root DefaultManual setup requiredBuilt-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

  1. Enable configuration cache: Add org.gradle.configuration-cache=true to gradle.properties. Speeds up CI builds significantly, reducing pipeline minutes even if it doesn’t shrink the final image directly.
  2. Use Shadow plugin wisely: Configure shadowJar to exclude META-INF signatures, README files, and unused service loader configs. These add kilobytes that accumulate.
  3. Kotlin daemon in containers: Always pass --no-daemon to Gradle in Docker. The daemon persists between builds in local dev but causes memory bloat and zombie processes in ephemeral CI containers.
  4. Exclude test fixtures: Ensure bootJar doesn’t include test classes or resources. Verify with jar 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.

Before850 MBFull JDK (350MB)Gradle Cache (200MB)Build Tools (150MB)OS Packages (100MB)App JAR (50MB)Single-stage buildNo JLinkOptimizeAfter135 MBCustom JRE (60MB)App JAR (50MB)Distroless Base (25MB)84% ReductionOptimization Impact BreakdownMulti-stage: -350MB (discard build tools)JLink: -290MB (custom JRE)Distroless: -75MBCumulative savings compound;each layer builds on the last.Measured: Kotlin 2.0 + Spring Boot 3.3Temurin 21 + Distroless java21-debian12August 2026 benchmarks
Size comparison demonstrating cumulative impact of multi-stage builds, JLink, and distroless bases when you shrink Kotlin Docker images for production Kubernetes deployments.

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.

Frequently Asked Questions

Eclipse Temurin JRE Alpine or Amazon Corretto minimal are typically smallest. Both run under 80MB and support Kotlin 2.x runtime requirements without unnecessary JDK tooling or shell utilities.

Use separate FROM statements in your Dockerfile. Build with Gradle or Maven in the first stage, then copy only the compiled JAR or native binary into a minimal runtime stage to discard build dependencies entirely.

Yes, native images often shrink containers from 300MB to under 50MB by compiling ahead-of-time. However, expect longer build times and potential reflection configuration issues requiring reachability metadata files.

Absolutely. The jlink tool strips unused JDK modules based on your application's actual dependencies. This typically removes 40-60% of runtime bloat compared to shipping a full JRE inside production containers.

The Spring Boot Gradle plugin or Cloud Native Buildpacks handle layer optimization automatically. They separate dependencies, resources, and classes into distinct layers so unchanged components cache efficiently during rebuilds.

Check for fat JAR bundling, debug symbols, or unnecessary resources. Run dive to inspect layer contents. Often developers forget to exclude test fixtures, documentation, or transitive dependencies that inflate final artifact size substantially.

Yes. Google distroless images contain no shell or package manager, reducing attack surface significantly. They work well with Kotlin since you only need the JVM runtime, not system utilities for debugging or maintenance tasks.

Teams running hundreds of pods typically save $200-$800 monthly on ECR or Artifact Registry storage plus bandwidth. Smaller images also reduce node provisioning costs by fitting more containers per instance.

Minimal overhead. The kotlinx-coroutines library adds roughly 1-2MB. Focus optimization efforts on JDK trimming and dependency management rather than coroutine libraries, which are already quite lean in recent versions.

No. Docker layers compress automatically during push and pull operations. Pre-compressing JARs prevents layer caching benefits and adds decompression CPU overhead at container startup without meaningful size reduction.

Scan with Trivy or Grype against CVE databases. Smaller images have fewer vulnerabilities by default, but validate that trimmed runtimes haven't removed security patches or certificate bundles required for TLS connections.

Expect 3-8 minutes versus seconds for JVM builds. Mitigate this with CI caching, incremental compilation, and parallel module processing. Reserve native compilation for production releases while using JVM mode during development cycles.

Yes. Both frameworks optimize for cloud-native deployment with faster startup and smaller footprints. Quarkus native binaries often reach 30-40MB, while Spring Boot native typically lands around 60-80MB for equivalent functionality.

Place frequently changing layers last. Copy dependency manifests before source code so cached dependency layers persist across code changes. This reduces rebuild time from minutes to seconds during active development iterations.

Occasionally. Some JNI libraries expect glibc and fail silently or crash on musl. Test thoroughly with native dependencies like database drivers or cryptography libraries before committing to Alpine for production workloads.