06 · Deployment (Docker)¶
A Kotlin service that only runs on your laptop isn't shipped. Docker
packages the JVM, your compiled classes, and everything they need into an
image that runs identically on any machine — your laptop, a teammate's,
or a production host. This module builds a tiny HTTP-shaped service,
containerizes it with a multi-stage Dockerfile, and covers the traps
that make Kotlin/JVM images bigger or slower to start than they need to
be.
The service¶
import java.time.Instant
data class HealthStatus(val status: String, val uptimeSeconds: Long, val checkedAt: String)
class Server(private val startedAt: Long = System.currentTimeMillis()) {
fun health(): HealthStatus {
val uptime = (System.currentTimeMillis() - startedAt) / 1000
return HealthStatus("UP", uptime, Instant.now().toString())
}
fun greet(name: String): String {
val port = System.getenv("PORT") ?: "8080"
return "Hello, $name! Serving on port $port"
}
}
fun main() {
val server = Server()
println(server.greet("world"))
println(server.health())
}
Compiling to a runnable jar and executing it with an environment variable set, exactly as a container would receive it:
$ kotlinc App.kt -include-runtime -d app.jar
$ PORT=9090 java -jar app.jar
Hello, world! Serving on port 9090
HealthStatus(status=UP, uptimeSeconds=0, checkedAt=2026-08-27T06:17:25.764283Z)
PORT came from the shell here; in a container it comes from the
orchestrator (Docker Compose, Kubernetes, a PaaS) via ENV or -e, and
the code doesn't change at all — this is the whole point of reading
configuration from the environment instead of hardcoding it.
Multi-stage Dockerfile¶
A naive Dockerfile installs the full Kotlin compiler and ships it in the final image just to run a jar that's already compiled. A multi-stage build separates "things needed to build" from "things needed to run":
# Stage 1: compile with the full Kotlin/JDK toolchain
FROM gradle:8.7-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle build --no-daemon -x test
# Stage 2: run on a slim JRE only — no compiler, no Gradle, no source
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The COPY --from=build line is the key mechanic: it reaches into the
first stage's filesystem and pulls out only the built jar, discarding
the ~600MB Gradle/JDK build stage entirely. The final image is built from
eclipse-temurin:21-jre-alpine (a JRE, not a full JDK, and Alpine Linux
rather than a full distro), which is typically 5-8x smaller than an image
that carries the whole build toolchain.
Building and running it locally follows the same commands regardless of which machine executes them:
$ docker build -t kotlin-service:latest .
$ docker run -p 8080:8080 -e PORT=8080 kotlin-service:latest
Hello, world! Serving on port 8080
HealthStatus(status=UP, uptimeSeconds=0, checkedAt=...)
.dockerignore¶
Without one, COPY . . in the build stage sends the entire project
directory — including .git, build/, and any local .gradle cache —
into the Docker build context, slowing every build and risking secrets
or stale artifacts leaking into the image:
JVM startup and container memory¶
Two JVM-specific traps show up almost immediately once a Kotlin service runs inside a container instead of on bare metal:
- The JVM used to ignore container memory limits entirely, reading
the host's total RAM via
/proc/meminfoand sizing its default heap off that — a container capped at 512MB could still let the JVM think it had 32GB to work with, right up until it got OOM-killed by the container runtime. Modern JDKs (10+) are container-aware by default, but it's still worth being explicit:-XX:MaxRAMPercentage=75.0caps the heap at a percentage of the container's actual limit rather than trusting either side's defaults. - JVM startup time (JIT warmup) is a real cost in container
orchestration, where a scheduler expects a health check to pass
within seconds. A Kotlin/JVM service that takes 3-4 seconds to become
responsive needs
initialDelaySecondsset accordingly in a Kubernetes readiness probe — or-Xshare:auto/ a smaller heap for common cases — otherwise the orchestrator can kill and restart a container that was simply still starting up, a restart loop that looks like a crash but is actually an impatient health check.
Traps¶
- Layer ordering defeats Docker's build cache.
COPY . .before runninggradle buildmeans any source change invalidates every layer after it, forcing a full dependency re-download each build. A faster pattern copiesbuild.gradle.kts/settings.gradle.ktsfirst, runs a dependency-resolution step, and only then copies source — so editing a.ktfile doesn't also re-fetch every Maven dependency. - Running as root inside the container is the default unless a
Dockerfile adds
USER appuserafter creating a non-root user — fine for a throwaway demo, a real liability if the container is ever compromised, since a root process inside the container that can escape to the host has full host privileges. EXPOSEin a Dockerfile is documentation, not enforcement. It doesn't open a port by itself;docker run -p 8080:8080(or the orchestrator's equivalent) is what actually maps the container's port to something reachable. Forgetting the-pflag produces a container that's running and healthy but completely unreachable from outside.-include-runtimeonkotlincbundles the Kotlin stdlib into the jar — convenient for a single-file demo like this module's, but a Gradle/Maven build with ashadowJar/fatJarplugin is the real-world equivalent, since it also merges dependency jars (not just the stdlib) into one runnable artifact, which is whatCOPY --from=buildabove expects to find.
How It Actually Works¶
System.getenv("PORT") reads directly from the OS process's environment
block — the same table of key/value strings every process on the machine
inherits from its parent at fork/exec time, whether that parent is your
shell (PORT=9090 java -jar app.jar) or the container runtime (docker run
-e PORT=8080, or Kubernetes injecting a Pod's env from a ConfigMap).
Nothing about the JVM or Kotlin changes based on who set that variable —
getenv is a thin wrapper over the platform's environ, so from the JVM's
point of view a container's injected environment variable is
indistinguishable from one set in a terminal, which is exactly why the code
doesn't need to know or care it's now running inside a container.
The multi-stage Dockerfile's size win comes from Docker's layered
filesystem model: each FROM/RUN/COPY instruction creates a new
image layer, and COPY --from=build copies files out of a previous
stage's final filesystem snapshot into the current stage's layers,
without pulling along any of that earlier stage's own layers (the Gradle
distribution, the downloaded dependency cache, the JDK's javac/kotlinc
binaries) — those live only in the intermediate build stage's image,
which Docker discards after the final stage is built, unless you explicitly
tag it. eclipse-temurin:21-jre-alpine is smaller for two independent
reasons: the JRE omits the compiler and dev tools a JDK carries (javac,
jshell, the full jmods set), and Alpine uses musl libc and
BusyBox instead of a full glibc-based userland, trading some binary
compatibility for a base image that's tens of megabytes instead of
hundreds. None of this touches how your compiled .jar runs — bytecode is
bytecode regardless of which JRE build executes it — it only affects how
much unrelated filesystem content ships alongside it.
Cheat sheet¶
| Concern | Approach |
|---|---|
| Small final image | Multi-stage build, COPY --from=build |
| Base image for running | eclipse-temurin:21-jre-alpine (JRE, not JDK) |
| Exclude files from build context | .dockerignore |
| Cap JVM heap to container limit | -XX:MaxRAMPercentage=75.0 |
| Map container port to host | docker run -p HOST:CONTAINER |
| Don't run as root | USER appuser in the Dockerfile |
| Fat jar with all dependencies | shadowJar/-include-runtime |
Exercise¶
Add a third stage to the Dockerfile above that runs gradle test before
the final FROM eclipse-temurin stage, so docker build fails the whole
build if any test fails — not just the app compile. Then modify Server
to read a SERVICE_NAME environment variable (defaulting to
"kotlin-service") and include it in greet's output; rebuild the jar
with kotlinc and confirm SERVICE_NAME=orders java -jar app.jar prints
orders in its greeting.