Back to Blog
Tutorial10 min read2026-06-29

How to Deploy a Ktor Kotlin API to Production

Ktor is JetBrains' lightweight Kotlin framework for asynchronous servers. This guide covers building a fat JAR with the Ktor Gradle plugin, configuring host and port, connecting PostgreSQL with HikariCP, and deploying.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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

That 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

  1. 1Provision a managed PostgreSQL (14.x or 16.x). PandaStack injects DATABASE_URL; convert it to JDBC form (or set JDBC_DATABASE_URL).
  2. 2Connect the Git repo as a container app. The Dockerfile is auto-detected.
  3. 3Set environment variables; the platform provides PORT, which the server reads.
  4. 4Let Flyway migrate at startup, or run a one-off migration step.
  5. 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, read PORT.
  • [ ] DATABASE_URL converted 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).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also