Back to Blog
Tutorial6 min read2026-07-11

How to Deploy a Ktor (Kotlin) App with PostgreSQL

Build a Ktor fat JAR, parse DATABASE_URL into HikariCP, run Flyway migrations safely, and ship the whole thing with a Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Ktor is JetBrains' unopinionated server framework for Kotlin: you get coroutine-based routing and not much else until you ask for it. That's great for keeping binaries lean, but it means production concerns — packaging, port binding, connection pooling, migrations — are yours to assemble. Here's the full path from ./gradlew run on your laptop to a container serving traffic, with PostgreSQL attached.

Building a fat JAR

Projects generated from [start.ktor.io](https://start.ktor.io) come with the Ktor Gradle plugin (io.ktor.plugin), which adds the packaging tasks you need:

./gradlew buildFatJar

This produces a self-contained JAR at build/libs/-all.jar with all dependencies bundled. Running it in production is one command:

java -jar build/libs/myapp-all.jar

If your entrypoint is EngineMain, make sure build.gradle.kts points at it, otherwise the fat JAR has no main class:

application {
    mainClass.set("io.ktor.server.netty.EngineMain")
}

One JVM-in-a-container gotcha worth fixing on day one: the default heap sizing can behave badly inside memory-limited containers. Cap it relative to the container limit instead of the host:

java -XX:MaxRAMPercentage=75 -jar myapp-all.jar

Binding to the right port and host

Ktor defaults to port 8080, but a platform will usually tell you where to listen via the PORT environment variable. With EngineMain, wire it up in src/main/resources/application.conf using HOCON's optional-override syntax:

ktor {
    deployment {
        port = 8080
        port = ${?PORT}
        host = "0.0.0.0"
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}

The second port line only applies if PORT is set, so local dev still works untouched. If you use embeddedServer instead of EngineMain, do the same thing in code:

fun main() {
    embeddedServer(
        Netty,
        port = System.getenv("PORT")?.toIntOrNull() ?: 8080,
        host = "0.0.0.0"
    ) {
        module()
    }.start(wait = true)
}

host = "0.0.0.0" matters. Binding to localhost inside a container means the health check dials your app and nobody answers — one of the most common "works locally, dies in prod" failures.

Connecting PostgreSQL from DATABASE_URL

Managed platforms typically hand you one connection string in the postgres://user:pass@host:5432/db format. JDBC won't accept that directly — it wants a jdbc:postgresql:// URL plus separate credentials — so parse it once at startup:

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import java.net.URI

fun createDataSource(): HikariDataSource {
    val uri = URI(System.getenv("DATABASE_URL") ?: error("DATABASE_URL not set"))
    val (user, pass) = uri.userInfo.split(":", limit = 2)

    val config = HikariConfig().apply {
        jdbcUrl = "jdbc:postgresql://${uri.host}:${uri.port}${uri.path}"
        username = user
        password = pass
        maximumPoolSize = 10
        isAutoCommit = false
    }
    return HikariDataSource(config)
}

Failing fast with error() when the variable is missing is deliberate — a stack trace at boot beats a NullPointerException on the first request.

If you're using Exposed as your ORM, hand it the pool:

Database.connect(createDataSource())

Keep maximumPoolSize modest. Ten connections per instance is plenty for most Ktor apps, and it keeps you comfortably inside the connection limits managed databases enforce.

Migrations with Flyway

Ktor has no migration story of its own, and Exposed's SchemaUtils.create() is not one — it can't evolve a schema, only create missing tables. Use Flyway. Add the dependencies (Flyway 10+ split the Postgres support into its own artifact):

implementation("org.flywaydb:flyway-core:10.17.0")
implementation("org.flywaydb:flyway-database-postgresql:10.17.0")

Write plain SQL migrations in src/main/resources/db/migration, named V1__create_users.sql, V2__add_index.sql, and so on. Then run them at startup, before the server begins accepting traffic:

fun runMigrations(dataSource: HikariDataSource) {
    Flyway.configure()
        .dataSource(dataSource)
        .load()
        .migrate()
}

Flyway takes a lock on its schema-history table, so two instances starting simultaneously won't both apply V3 — one migrates, the other waits. That makes migrate-on-boot acceptable for Ktor in a way it isn't for tools without locking. For long-running migrations on a busy table, still prefer running Flyway as a separate step before rollout so a slow ALTER TABLE doesn't hold your deploy hostage.

The Dockerfile

Two stages: Gradle builds the fat JAR, a slim JRE image runs it.

FROM gradle:8-jdk17 AS build
WORKDIR /app
COPY . .
RUN gradle buildFatJar --no-daemon

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
EXPOSE 8080
CMD ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

--no-daemon keeps Gradle from leaving a background process alive in a container that's about to be discarded. The runtime image is a JRE, not a JDK — you don't need a compiler in production, and the image is meaningfully smaller.

Deploying on PandaStack

With the pieces above in place, the deploy itself is short:

  1. 1Create a managed PostgreSQL instance (14.x or 16.x) from the dashboard. Backups run daily, retained 7 days on the free plan, 15 on Pro, 30 on Premium.
  2. 2Connect your Git repo as a container app. PandaStack builds from your Dockerfile using rootless BuildKit in an ephemeral Kubernetes Job — no host Docker daemon touching your build.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically, which is exactly the format the parsing code above expects. No copying credentials between tabs.
  4. 4Push to your branch. The build and deploy run automatically, and the build logs stream live, so when Gradle's dependency resolution takes a while you can watch it happen instead of staring at a spinner.

Add any other configuration — JAVA_OPTS, feature flags, API keys — as environment variables in the app settings.

Two honest notes for JVM apps specifically. First, Gradle builds aren't fast; the free tier includes 300 build-pipeline minutes a month, which is fine for a side project but worth watching if you push twenty times a day. Second, free-tier apps scale to zero when idle, and a JVM cold start is slower than a Go binary's — a hobby Ktor app will notice the first request after a quiet spell. Paid tiers keep instances warm on stable nodes.

Pre-launch checklist

CheckWhy
host = "0.0.0.0"Container health checks can't reach localhost
PORT read from envThe platform decides, not your config file
MaxRAMPercentage setDefault JVM heap sizing misreads container limits
Flyway, not SchemaUtilsSchema evolution needs versioned, forward-only migrations
Pool size ≤ connection limitManaged Postgres enforces per-plan connection caps
Fail fast on missing env varsBoot-time errors are debuggable; request-time ones page you

None of this is exotic — it's the same production hygiene every JVM service needs, applied to Ktor's do-it-yourself philosophy. If you want to see how it feels when the database wiring is already done for you, try it on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also