Ktor is JetBrains' own framework for building asynchronous servers and clients in Kotlin. It's lightweight, idiomatic, coroutine-first, and avoids the heavy annotation-driven magic of traditional JVM frameworks. If you want a modern Kotlin backend without dragging in the full Spring stack, Ktor is a fantastic fit. Deploying it is straightforward once you decide how to package the JVM app. This guide covers both fat-JAR and container approaches.
How Ktor is structured
A Ktor app is configured either in code or via an application.conf (HOCON) file. You install *features* (plugins) — content negotiation, authentication, call logging, CORS — and define routing. It runs on an engine (Netty is the common choice). The deployment question is purely "how do I get the JVM to run my app in a container," and there are two clean answers.
Step 1: Read the port from the environment
Whatever else you do, the app must listen on the platform-provided port and bind to all interfaces. With the embedded server:
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.response.*
fun main() {
val port = System.getenv("PORT")?.toInt() ?: 8080
embeddedServer(Netty, port = port, host = "0.0.0.0") {
routing {
get("/health") { call.respondText("ok") }
}
}.start(wait = true)
}If you use application.conf instead, parameterize the port:
ktor {
deployment { port = 8080, port = ${?PORT} }
application { modules = [ com.example.ApplicationKt.module ] }
}Step 2: Choose your packaging
| Approach | How | Pros | Cons |
|---|---|---|---|
| Fat JAR | Shadow/Assembly plugin builds one runnable JAR | Simple, portable | Need a JRE base image |
| Multi-stage container | Gradle builds inside Docker | Reproducible builds | Slightly more Dockerfile |
Most teams build a fat JAR (a single JAR with all dependencies) and run it on a slim JRE image. The Gradle Shadow plugin makes this one task.
Step 3: Build a fat JAR
With the Shadow plugin in build.gradle.kts:
plugins {
application
id("com.github.johnrengelman.shadow") version "8.1.1"
}
application {
mainClass.set("com.example.ApplicationKt")
}Then ./gradlew shadowJar produces build/libs/your-app-all.jar.
Step 4: The Dockerfile (multi-stage)
Build with the JDK, run on a slim JRE:
# ---- build stage ----
FROM gradle:8.10-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle shadowJar --no-daemon
# ---- runtime stage ----
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]Using a JRE (not JDK) runtime image keeps it lean. Temurin's JRE images are a solid, well-maintained base.
Step 5: Tune the JVM for containers
Modern JVMs are container-aware, but it's wise to be explicit so the heap respects your memory limit:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]MaxRAMPercentage tells the JVM to size its heap relative to the container's memory limit, avoiding OOM kills from an oversized heap. This is the single most important JVM-in-a-container setting.
Step 6: Add a database
For Postgres, Exposed (JetBrains' Kotlin SQL framework) or plain HikariCP + JDBC both work. Read the connection string from the environment:
import com.zaxxer.hikari.*
val config = HikariConfig().apply {
jdbcUrl = System.getenv("DATABASE_URL") // jdbc:postgresql://...
maximumPoolSize = 5
driverClassName = "org.postgresql.Driver"
}
val dataSource = HikariDataSource(config)Note: JDBC expects a jdbc:postgresql://... URL. If your platform injects a postgres://... URL, transform it (prepend jdbc: and move credentials to properties) at startup.
Step 7: Deploy on PandaStack
- 1Provision a managed PostgreSQL (16.x).
- 2Create a container app from your repo. PandaStack builds the Dockerfile with rootless BuildKit and streams live build logs while Gradle compiles.
- 3Link the database — a connection string is auto-injected; adapt it to JDBC form if needed.
- 4Set
JAVA_OPTS/ JVM flags as needed and ensure the app readsPORT. - 5Attach a custom domain with automatic SSL.
Alternatively, if you prefer buildpacks, PandaStack auto-detects Gradle/JVM projects — but the explicit Dockerfile gives you full control over the JRE base and JVM flags.
Production polish
- Call logging — install the
CallLoggingplugin so requests appear in live logs. - Content negotiation — install
ContentNegotiationwith kotlinx.serialization for JSON. - Graceful shutdown — Ktor handles SIGTERM; configure a shutdown grace period so deploys drain requests.
- Health endpoint — keep
/healthcheap for readiness probes.
Honest caveats
The JVM trade-offs apply: compared to a Rust or Go binary, a Ktor container carries a JVM, uses more baseline memory, and has a slower cold start (JVM warm-up). For scale-to-zero free-tier scenarios, that cold-start cost is real — though Ktor is much lighter than Spring Boot, so it's a relatively fast-starting JVM app. The upside is the rich JVM ecosystem and Kotlin's excellent developer experience. Set MaxRAMPercentage and give the container adequate memory and you'll have a smooth ride.
Wrapping up
Deploying Ktor is a clean JVM story: build a fat JAR (or multi-stage container), run it on a slim JRE, set MaxRAMPercentage, bind to PORT/0.0.0.0, and connect to managed Postgres via HikariCP. You get a lightweight, idiomatic Kotlin backend without framework bloat.
PandaStack builds your Dockerfile (or auto-detects Gradle), auto-wires a managed database, and serves with automatic SSL — and the free tier is enough to run a real Ktor service. Deploy yours at https://dashboard.pandastack.io.
References
- Ktor documentation: https://ktor.io/docs/
- Ktor deployment / fat JAR: https://ktor.io/docs/server-fatjar.html
- Ktor in Docker: https://ktor.io/docs/docker.html
- JVM container awareness (MaxRAMPercentage): https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
- HikariCP connection pooling: https://github.com/brettwooldridge/HikariCP