Configure a Ktor 2.x Server with Netty, Routing, JSON Serialization, and Graceful Shutdown
Step-by-step guide to building a production-ready Ktor 2.x server on Netty with kotlinx.serialization JSON, typed routing, StatusPages error mapping, HOCON config, and graceful SIGTERM shutdown. Includes aligned dependencies, plugin order, runnable code, curl verification, and testApplication tests.
27 May 2026, 10:04 UTC

Problem and Takeaway
You need a production-ready Ktor 2.x HTTP server on the Netty engine that handles JSON request/response bodies, maps exceptions to proper HTTP status codes, and shuts down cleanly on SIGTERM. This guide walks through the minimal dependency set, plugin installation order, configuration externalization, and verification steps so you can ship a working service without version conflicts or runtime surprises.
Prerequisites
- JDK 17+ installed and on
PATH - Gradle 8.x (or Maven 3.9+) for dependency resolution
- Kotlin 1.9+ toolchain configured in the build
- Basic familiarity with Kotlin coroutines and HOCON syntax
Dependency Alignment
Ktor 2.3.x, Kotlin 1.9+, kotlinx.serialization 1.5+, and Netty 4.1.100+ must move together. A single version mismatch produces NoSuchMethodError at startup. Declare versions in gradle/libs.versions.toml (or build.gradle.kts):
[versions]
ktor = "2.3.8"
kotlin = "1.9.23"
serialization = "1.6.2"
netty = "4.1.108.Final"
[libraries]
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-serialization-kotlinx = { module = "io.ktor:ktor-serialization-kotlinx", version.ref = "ktor" }
ktor-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }Apply the Kotlin serialization plugin in build.gradle.kts:
plugins {
kotlin("jvm") version "1.9.23"
kotlin("plugin.serialization") version "1.9.23"
application
}
dependencies {
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.core)
implementation(libs.ktor.serialization.kotlinx)
implementation(libs.ktor.status.pages)
implementation(libs.kotlinx.serialization)
testImplementation(libs.ktor.server.test.host)
testImplementation(kotlin("test"))
}Application Structure
Create three files: a data model, the main module, and an application.conf for external config.
Serializable Data Classes
Place models in src/main/kotlin/com/example/model.kt. The @Serializable annotation enables automatic JSON mapping.
package com.example
import kotlinx.serialization.Serializable
@Serializable
sealed class ApiResponse {
@Serializable
data class Ok(val payload: Any) : ApiResponse()
@Serializable
data class Error(val code: String, val message: String) : ApiResponse()
}
@Serializable
data class HealthResponse(val status: String = "OK", val version: String)
@Serializable
data class EchoRequest(val message: String)
@Serializable
data class EchoResponse(val echo: String, val receivedAt: Long = System.currentTimeMillis())Note: Sealed classes require @Serializable on the parent and every subclass; missing annotations throw SerializerNotFoundException at runtime.
HOCON Configuration
Create src/main/resources/application.conf. Keys are case-sensitive.
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ "com.example.ApplicationKt.module" ]
}
}
app {
version = "1.0.0"
}The ${?PORT} syntax allows container overrides (e.g., PORT=8081 java -jar ...).
Main Module with Plugins and Routing
File: src/main/kotlin/com/example/Application.kt. Plugin installation order matters: ContentNegotiation before routing, StatusPages early for exception mapping.
package com.example
import io.ktor.server.application.*
import io.ktor.server.config.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
fun Application.module() {
install(CallLogging) // optional, logs each request
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(StatusPages) {
exception { call.respond(status = 400, ApiResponse.Error("BAD_REQUEST", it.message ?: "Invalid input")) }
exception { call.respond(status = 500, ApiResponse.Error("INTERNAL", "Unexpected error")) }
}
routing {
get("/health") {
val version = environment.config.property("app.version").getString()
call.respond(HealthResponse(version = version))
}
post("/echo") {
val req = call.receive()
call.respond(EchoResponse(echo = req.message))
}
get("/fail") {
throw IllegalArgumentException("demo failure")
}
}
}
fun main() {
val server = embeddedServer(Netty) {
module()
}
Runtime.getRuntime().addShutdownHook(Thread { server.close() })
server.start(wait = true)
}Graceful Shutdown Mechanics
The Runtime.getRuntime().addShutdownHook registers a JVM hook that calls server.close(). Netty's EventLoopGroup shuts down its boss/worker threads automatically. For stricter control, use server.stop(0, 0, TimeUnit.SECONDS) to wait zero seconds for quiet period and timeout. If you run blocking JDBC calls, configure a custom EventLoopGroup or offload to Dispatchers.IO to avoid starving Netty's event loops.
Running and Verifying
Start the Server
From the project root (requires execute permission on gradlew):
./gradlew runExpected log lines:
[main] INFO Application - Application started in 0.345 seconds
[main] INFO Netty - Netty started on port(s): 8080Health Check
Run from a separate terminal:
curl -v -H 'Accept: application/json' localhost:8080/healthExpected response (HTTP 200):
{"status":"OK","version":"1.0.0"}Echo Endpoint
curl -v -H 'Content-Type: application/json' -d '{"message":"hello"}' localhost:8080/echoExpected response (HTTP 200):
{"echo":"hello","receivedAt":1728654321000}Error Mapping
curl -v localhost:8080/failExpected response (HTTP 400):
{"code":"BAD_REQUEST","message":"demo failure"}Graceful Shutdown Test
Find the PID (jps -l or ps aux | grep ApplicationKt), then send SIGTERM:
kill -TERM <pid>Logs should show:
[Thread-0] INFO Application - Graceful shutdown completedProcess exits with code 0.
Automated Testing with testApplication
Add a test in src/test/kotlin/com/example/HealthRouteTest.kt. The test host simulates requests without binding a real port.
package com.example
import io.ktor.server.testing.*
import io.ktor.http.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class HealthRouteTest {
@Test
fun `health endpoint returns OK with version`() = testApplication {
application {
environment {
config = testApplicationEngine().environment.config
start()
}
module()
}
client.get("/health").apply {
assertEquals(HttpStatusCode.OK, status)
val body = bodyAsText()
val parsed = Json.decodeFromString(body)
assertEquals("OK", parsed.status)
assertEquals("1.0.0", parsed.version)
}
}
@Test
fun `echo endpoint round-trips JSON`() = testApplication {
application { module() }
client.post("/echo") {
contentType(ContentType.Application.Json)
setBody(EchoRequest("test"))
}.apply {
assertEquals(HttpStatusCode.OK, status)
val resp = bodyAsText().let { Json.decodeFromString(it) }
assertEquals("test", resp.echo)
}
}
}
Run tests:
./gradlew testCommon Pitfalls and Diagnostics
| Symptom | Likely Cause | Fix |
|---|---|---|
NoSuchMethodError on startup | Ktor/Kotlin/serialization version mismatch | Align versions in libs.versions.toml; run ./gradlew dependencyInsight --dependency ktor-server-core |
| JSON returns 415/500 | ContentNegotiation installed after routing | Move install(ContentNegotiation) before routing { } |
SerializerNotFoundException | Missing @Serializable on sealed subclass | Annotate every subclass; rebuild |
| Port already in use | Previous process didn't shut down | kill -9 <pid> or change ktor.deployment.port |
| Blocking calls hang | JDBC on Netty event loop | Use withContext(Dispatchers.IO) or custom EventLoopGroup |
Limitations
- This guide covers the Netty engine only; other engines (CIO, Jetty) have different thread models.
- No authentication, rate limiting, or metrics are included; add
ktor-server-auth,ktor-server-rate-limit, or Micrometer as needed. - HOCON config reload at runtime is not supported; restart required for changes.
- Sealed class serialization requires all subclasses known at compile time; open hierarchies need custom serializers.
Verification Checklist
./gradlew runstarts without errors and logs Netty bound port.curl /healthreturns 200 with correct JSON structure.curl /echoround-trips payload with matchingreceivedAttimestamp.curl /failreturns 400 withApiResponse.Errorbody.kill -TERMproduces graceful shutdown log and exit code 0../gradlew testpasses both test cases.
If all six checks pass, the server is ready for containerization or deployment.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.