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 buildFatJarThis produces a self-contained JAR at build/libs/ with all dependencies bundled. Running it in production is one command:
java -jar build/libs/myapp-all.jarIf 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.jarBinding 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:
- 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.
- 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.
- 3Attach the database to the app.
DATABASE_URLis injected automatically, which is exactly the format the parsing code above expects. No copying credentials between tabs. - 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
| Check | Why |
|---|---|
host = "0.0.0.0" | Container health checks can't reach localhost |
PORT read from env | The platform decides, not your config file |
MaxRAMPercentage set | Default JVM heap sizing misreads container limits |
Flyway, not SchemaUtils | Schema evolution needs versioned, forward-only migrations |
| Pool size ≤ connection limit | Managed Postgres enforces per-plan connection caps |
| Fail fast on missing env vars | Boot-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.