Ktor is JetBrains' Kotlin-first framework for building asynchronous servers (and clients). It's lightweight, coroutine-based, and configured almost entirely in code. Deploying it to production means producing a runnable JAR, binding correctly inside a container, and wiring up a proper JDBC connection pool. Here's the full path.
A minimal Ktor server
Using the Netty engine and content negotiation:
// src/main/kotlin/Application.kt
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() {
val port = System.getenv("PORT")?.toInt() ?: 8080
embeddedServer(Netty, port = port, host = "0.0.0.0") {
module()
}.start(wait = true)
}
fun Application.module() {
routing {
get("/health") { call.respondText("""{"status":"ok"}""", io.ktor.http.ContentType.Application.Json) }
}
}Note host = "0.0.0.0" and reading PORT from the environment — the two container essentials. By default embeddedServer may bind to 0.0.0.0 already, but being explicit avoids surprises.
Building a runnable JAR
The Ktor Gradle plugin builds a self-contained "fat JAR" with all dependencies. In build.gradle.kts:
plugins {
kotlin("jvm") version "2.0.0"
id("io.ktor.plugin") version "3.0.0"
}
application {
mainClass.set("ApplicationKt")
}Then build:
./gradlew buildFatJar
# Produces build/libs/<name>-all.jarThat single JAR is everything you need to run with java -jar.
Connecting PostgreSQL with HikariCP
Ktor doesn't bundle a database layer — you bring your own. HikariCP is the standard JDBC connection pool; pair it with Exposed (JetBrains' Kotlin SQL framework) or raw JDBC:
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
fun createDataSource(): HikariDataSource {
val config = HikariConfig().apply {
jdbcUrl = System.getenv("JDBC_DATABASE_URL")
maximumPoolSize = 10
isAutoCommit = false
driverClassName = "org.postgresql.Driver"
}
return HikariDataSource(config)
}A wrinkle: managed platforms usually provide DATABASE_URL in the postgres://user:pass@host:port/db form, but JDBC wants jdbc:postgresql://host:port/db with credentials passed separately. Convert it at startup, or set a JDBC_DATABASE_URL env var in the JDBC format. This conversion is the most common Kotlin/JVM deployment snag.
fun toJdbcUrl(databaseUrl: String): String {
val uri = java.net.URI(databaseUrl)
val userInfo = uri.userInfo.split(":")
return "jdbc:postgresql://${uri.host}:${uri.port}${uri.path}?user=${userInfo[0]}&password=${userInfo[1]}"
}Migrations with Flyway
Flyway is the go-to JVM migration tool. Place versioned SQL in src/main/resources/db/migration and run on startup or as a deploy step:
import org.flywaydb.core.Flyway
Flyway.configure().dataSource(dataSource).load().migrate()Running Flyway at app startup is acceptable because it takes a lock, so concurrent replicas won't double-apply — but for large schemas, a dedicated deploy step is cleaner.
Dockerfile
FROM gradle:8-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle buildFatJar --no-daemon
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
ENV PORT=8080
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]The runtime image uses a JRE (not the full JDK) to stay smaller. JVM startup is heavier than Rust or Go — relevant if you run on scale-to-zero, where the first request pays JVM boot time.
Deploying on PandaStack
- 1Provision a managed PostgreSQL (14.x or 16.x). PandaStack injects
DATABASE_URL; convert it to JDBC form (or setJDBC_DATABASE_URL). - 2Connect the Git repo as a container app. The Dockerfile is auto-detected.
- 3Set environment variables; the platform provides
PORT, which the server reads. - 4Let Flyway migrate at startup, or run a one-off migration step.
- 5Add a custom domain; SSL is automatic.
Because the JVM has a non-trivial cold start, prefer always-on compute for latency-sensitive APIs; scale-to-zero is fine for internal or low-traffic services where occasional cold starts are acceptable.
Production checklist
- [ ] Fat JAR via
buildFatJar; JRE-only runtime image. - [ ] Bind
0.0.0.0, readPORT. - [ ]
DATABASE_URLconverted to JDBC format. - [ ] HikariCP pool sized under the DB tier's connection limit.
- [ ] Flyway migrations applied.
- [ ] Consider always-on compute due to JVM startup time.
Verifying
curl -s https://api.example.com/health
# {"status":"ok"}A healthy response from the JAR confirms your build, binding, and DB conversion are correct.
References
- [Ktor deployment documentation](https://ktor.io/docs/server-deployment.html)
- [Ktor fat JAR with Gradle](https://ktor.io/docs/server-fatjar.html)
- [HikariCP configuration](https://github.com/brettwooldridge/HikariCP)
- [Flyway migrations](https://documentation.red-gate.com/fd/flyway-documentation-138346877.html)
---
A Ktor fat JAR plus a managed PostgreSQL deploys cleanly on PandaStack — Dockerfile auto-detected and DATABASE_URL injected (convert to JDBC form). Start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).