Dockerize a Kotlin App with Multi-Stage Builds

Khimananda Oli 10 min read Programming and Languages
Dockerize a Kotlin App with Multi-Stage Builds

By Khimananda Oli | Last reviewed: August 2026

Kotlin applications compiled on the JVM often result in Docker images exceeding 800MB when built naively, creating slow deployments and expanded attack surfaces. To Dockerize a Kotlin app with multi-stage builds effectively, you must separate the compilation environment from the runtime environment within your Dockerfile. This approach discards the JDK, Gradle caches, and source code after building, leaving only the minimal JRE and application artifacts required for execution.

How do you structure a multi-stage Dockerfile for Kotlin?

The architecture of an efficient Kotlin container relies on isolating dependencies that are strictly necessary for compilation from those needed at runtime. When you Dockerize a Kotlin app with multi-stage builds, you are essentially creating two distinct environments in a single file. The first stage acts as a factory; the second acts as the shipping container.

STAGE 1: Builder (JDK + Gradle)Source CodeGradle CacheCompile & TestApp JAR / DistSTAGE 2: Runtime (JRE Only)Alpine / Slim JRECopied ArtifactFinal Image <200MBCOPY --from=builder
Multi-stage build flow: Build artifacts transfer from heavy JDK stage to lightweight JRE runtime stage

In practice, the most common mistake engineers make is failing to leverage Gradle's dependency caching layer. If you copy your entire project directory before running the build, every source code change invalidates the Docker layer cache, forcing a full re-download of dependencies. Instead, structure your Dockerfile to copy build.gradle.kts and settings.gradle.kts first, run a dependency resolution task, and only then copy the source code. This pattern is critical for fast CI/CD pipelines, especially when working with teams across Nepal and global time zones where build feedback loops directly impact velocity.

Defining the builder stage correctly

Your builder stage should use a full JDK image matching your target Java version. For Kotlin 2.x projects in 2026, Java 21 LTS is the standard baseline. Use the official Eclipse Temurin images for consistent, license-friendly builds. Always set the working directory explicitly and configure Gradle to avoid running as root, which prevents permission issues when copying artifacts to the final stage.

# Stage 1: Builder
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app

# Copy gradle wrapper and config first for better caching
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle

# Download dependencies (cached unless build files change)
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon

# Now copy source and build
COPY src ./src
RUN ./gradlew bootJar --no-daemon --stacktrace

Configuring the runtime stage for minimal footprint

The runtime stage should never contain a compiler, shell utilities, or package managers unless absolutely required for debugging. Use eclipse-temurin:21-jre-alpine or the newer eclipse-temurin:21-jre-noble if you require glibc compatibility for specific native libraries. Create a non-root user, set appropriate file permissions, and expose only the necessary ports. This aligns with the principles discussed in container image scanning with Trivy, where reducing the number of installed packages directly correlates to fewer CVE findings.

What is the optimal Gradle configuration for containerized Kotlin builds?

Gradle behaves differently inside containers than on developer workstations. Without proper tuning, you will encounter out-of-memory errors during compilation or excessively long build times due to daemon overhead. When you Dockerize a Kotlin app with multi-stage builds, the Gradle daemon provides no benefit because each Docker layer is immutable; the daemon cannot persist state between runs.

  • Disable the daemon: Always pass --no-daemon to Gradle commands in Dockerfiles. The daemon startup cost exceeds its benefit in ephemeral build environments.
  • Enable parallel execution: Add org.gradle.parallel=true to your gradle.properties to utilize all available CPU cores during compilation.
  • Configure memory limits: Set org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m to prevent the JVM from consuming all container memory and triggering OOM kills.
  • Use reproducible builds: Enable tasks.withType<AbstractArchiveTask> { isPreserveFileTimestamps = false; isReproducibleFileOrder = true } in your build script to ensure identical inputs produce byte-for-byte identical outputs, improving layer cache hit rates.

For teams managing multiple microservices, consider applying these settings globally through a shared convention plugin rather than repeating them in every Dockerfile. This reduces drift and ensures consistent build behavior across your portfolio, whether you are deploying to AWS EKS or a local Kubernetes cluster as described in Amazon EKS practical guide.

Handling Kotlin-specific compilation nuances

Kotlin's incremental compilation can cause issues in Docker if not configured properly. The Kotlin compiler maintains caches that may become stale across layer boundaries. In your build.gradle.kts, explicitly configure the Kotlin JVM target to match your runtime JRE version. Mismatched targets (e.g., compiling with Java 21 but targeting Java 17 bytecode while running on Java 21) work but miss performance optimizations available in newer bytecode versions.

kotlin {
    jvmToolchain(21)
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict", "-Xemit-jvm-type-annotations")
    }
}

How do you reduce Kotlin Docker image size below 200MB?

Image size directly impacts deployment speed, autoscaling responsiveness, and storage costs. A typical unoptimized Kotlin Spring Boot image weighs 800–950MB. After applying multi-stage builds correctly, you should target 150–190MB for most web applications. The difference lies in base image selection and artifact optimization.

Base Image StrategyApproximate SizeSecurity SurfaceCompatibility Notes
Full JDK (eclipse-temurin:21-jdk)~900 MBHigh (compiler, shell, pkg mgr)Never use for production runtime
JRE Standard (eclipse-temurin:21-jre)~280 MBMedium (includes unnecessary libs)Safe default for most apps
JRE Alpine (eclipse-temurin:21-jre-alpine)~160 MBLow (musl libc, minimal tools)Test native libs thoroughly
JLink Custom Runtime~120 MBVery Low (only required modules)Requires module-info.java or jdeps analysis

For most Kotlin web applications in 2026, Alpine-based JRE images offer the best balance. However, if your application uses JNI libraries like netty-tcnative or certain cryptography providers, musl libc incompatibilities may cause runtime failures. In such cases, use the Ubuntu Noble-based slim JRE variant instead. Always validate your choice by running integration tests against the actual production image, not just the builder output.

Kotlin Docker Image Size Comparison (2026)0 MB250 MB500 MB750 MB1000 MB900 MBFull JDK280 MBJRE Standard160 MBJRE Alpine120 MBJLink Custom
Visual comparison showing 85% size reduction from Full JDK to optimized JLink custom runtime

If you need to go below 150MB, use jlink to create a custom runtime containing only the Java modules your Kotlin application actually uses. This requires analyzing your dependencies with jdeps first. While more complex to maintain, this approach is valuable for serverless deployments or edge computing scenarios where every megabyte affects cold start latency and billing. Document your module list in version control so future developers understand why specific modules were included.

How do you handle secrets and security in Kotlin container builds?

Security cannot be an afterthought when containerizing JVM applications. Build-time secrets (like private Maven repository credentials) must never appear in image layers. Runtime secrets should be injected via environment variables or mounted volumes, never baked into the JAR. This discipline is essential for passing SOC 2 audits and maintaining compliance in regulated environments.

  1. Use BuildKit secret mounts: Pass sensitive build arguments using RUN --mount=type=secret,id=gradle_properties,target=/root/.gradle/gradle.properties instead of ARG directives. Secrets mounted this way never persist in any layer.
  2. Create a non-root user: Always add RUN addgroup -S appgroup && adduser -S appuser -G appgroup and switch to it with USER appuser before the ENTRYPOINT. Running as root inside containers violates least-privilege principles and enables container escape vulnerabilities.
  3. Set read-only filesystems: Configure your container runtime to mount the root filesystem as read-only, with explicit tmpfs mounts for directories requiring write access (/tmp, log directories). This prevents attackers from modifying binaries post-exploitation.
  4. Scan before pushing: Integrate vulnerability scanning into your CI pipeline. Tools like Trivy or Grype should fail builds on critical CVEs. Refer to DevSecOps shift-left practices for implementation patterns that catch issues before they reach production registries.

Managing Gradle private repository authentication

Many organizations host internal Kotlin libraries on private Artifactory or Nexus instances. Authenticating during Docker builds requires careful handling. Store credentials in your CI system's secret store, mount them during the dependency resolution phase, and ensure they are not copied into subsequent stages. Never commit gradle.properties containing passwords to version control, even if gitignored — accidental commits happen frequently under deadline pressure.

# Secure dependency resolution with BuildKit secrets
RUN --mount=type=secret,id=gradle_creds,target=/root/.gradle/gradle.properties \
    ./gradlew dependencies --no-daemon

# Credentials are NOT present in this or any subsequent layer
COPY src ./src
RUN ./gradlew bootJar --no-daemon

What are common pitfalls when Dockerizing Kotlin applications?

Even experienced engineers encounter subtle issues when transitioning Kotlin applications to containers. Understanding these failure modes prevents production incidents and debugging sessions at inconvenient hours.

  • Timezone mismatches: Alpine images default to UTC. If your Kotlin code uses LocalDateTime.now() without explicit timezone configuration, logs and business logic timestamps will differ from your Nepal-based team's expectations. Set TZ=Asia/Kathmandu in your Dockerfile or configure the JVM with -Duser.timezone=Asia/Kathmandu.
  • Missing CA certificates: Minimal JRE images sometimes lack complete certificate bundles. If your application calls external HTTPS APIs and fails with SSL handshake errors, install ca-certificates package in your runtime stage or use a base image known to include them.
  • Incorrect health check paths: Spring Boot Actuator endpoints may be disabled or secured in production profiles. Verify your HEALTHCHECK directive targets an accessible endpoint that accurately reflects application readiness, not just HTTP 200 responses.
  • Ignoring SIGTERM handling: Kotlin applications must gracefully shut down when receiving termination signals. Ensure your entrypoint uses exec form (ENTRYPOINT ["java", "-jar", "app.jar"]) rather than shell form to allow signal propagation. Without this, Kubernetes pod terminations will always hit the grace period timeout, causing request drops during rolling updates.
Build/Runtime Issue?Image too large?Use Multi-Stage + AlpineRuntime crash/error?SSL/TLS failure?Install ca-certificatesSignal/shutdown issue?Use exec form ENTRYPOINTYESNOYESNO
Troubleshooting decision tree for Kotlin Docker build and runtime problems

Debugging layer cache misses

When builds suddenly take 10 minutes instead of 30 seconds, layer cache invalidation is usually the culprit. Use docker build --progress=plain to see exactly which layer broke the cache. Common causes include non-deterministic file ordering in COPY commands, timestamp differences, or unintended modifications to gradle wrapper scripts. Pin your Gradle wrapper version in version control and verify checksums to ensure reproducibility across developer machines and CI runners.

Deploying Optimized Kotlin Containers Effectively

Successfully implementing multi-stage builds is only half the equation. You must also configure your orchestration platform to leverage the smaller images effectively. Set appropriate resource requests and limits based on actual profiling, not guesses. A 180MB Kotlin container typically needs 256–512MB memory limit depending on heap configuration. Monitor garbage collection metrics to tune JVM flags like -XX:MaxRAMPercentage=75.0 rather than hardcoding heap sizes, which breaks when container limits change. For comprehensive observability setup post-deployment, review Prometheus and Grafana monitoring stack to ensure your optimized containers remain visible and debuggable in production.

If your team is adopting containerized Kotlin services and needs guidance on secure, compliant deployment patterns, reach out to discuss your infrastructure requirements. Proper containerization foundations prevent costly rework during security audits and scaling events.

Frequently Asked Questions

It separates compilation and runtime into distinct stages, copying only the final JAR to a slim image. This reduces attack surface and image size significantly compared to single-stage builds that include the full JDK and build tools.

Use eclipse-temurin:21-jdk-alpine for building and eclipse-temurin:21-jre-alpine for runtime in 2026. Alpine variants minimize size, while Temurin provides reliable OpenJDK builds with active security patches specifically suited for containerized Kotlin applications.

Copy build.gradle.kts and settings.gradle.kts first, run gradle dependencies, then copy source code. This layer caching prevents re-downloading dependencies on every code change, dramatically speeding up subsequent Docker builds during development cycles.

You likely included the full JDK in the final stage or copied unnecessary build artifacts. Switch to a JRE-only runtime image and verify your COPY command targets only the built JAR file, not the entire build directory.

Yes. BuildKit enables parallel stage execution and better caching. Enable it via DOCKER_BUILDKIT=1 or docker buildx. It also supports cache mounts for Gradle, keeping dependency downloads persistent across builds without bloating image layers.

Use GraalVM native-image in the build stage to compile ahead-of-time. The resulting binary starts in milliseconds versus seconds for JVM-based containers, though build times increase and reflection requires explicit configuration.

Expose whatever port your framework binds to, typically 8080 for Ktor or Spring Boot. Always match the EXPOSE directive and any health check ports to your application.properties or environment variable configuration to avoid connectivity failures.

Externalize configuration using environment variables or mounted config files rather than baking values into the image. Use Kotlin libraries like hoplite or spring-cloud-config to read from ENV vars, keeping images immutable across staging and production.

Initial builds take longer due to extra stages, but cached rebuilds are faster since dependency layers persist. The tradeoff favors multi-stage because smaller images deploy quicker, scan faster, and consume less registry storage and bandwidth.

Run docker build with --progress=plain to see full output. Inspect intermediate stages using docker buildx debug or by adding temporary RUN echo statements. Check Gradle logs specifically, as build failures often stem from missing plugins or network timeouts.

Fat JARs simplify deployment but prevent layer caching of dependencies. Exploded layouts let Docker cache library layers separately from application code. For frequently changing apps, exploded format with proper COPY ordering yields faster rebuilds in CI pipelines.

Run as non-root user, use read-only filesystems, scan images with trivy, and keep base images updated. Remove shell access in runtime stages and never embed secrets in Dockerfiles. Sign images with cosign for supply chain integrity.

Structurally identical, but Kotlin projects often use different plugins like kotlin-jvm or ktor. Ensure your build stage invokes the correct Gradle tasks and that reflection-heavy Kotlin features are handled if using native compilation or ProGuard optimization.

Yes, pass -x test to Gradle in the build stage. Run tests in CI before building instead. Including tests in Docker builds wastes time and may fail due to missing test infrastructure like databases or external service mocks unavailable in containers.

Use -XX:MaxRAMPercentage=75.0 instead of fixed heap sizes. This respects container memory limits dynamically. Combine with UseContainerSupport flag, enabled by default in JDK 21, to prevent OOMKills when running under cgroup memory constraints.