Ktor is JetBrains’ backend framework for Kotlin. If you already write Kotlin for Android or KMP, you can build a server with the same language, the same coroutines, and no new syntax to learn.
This guide builds one real backend from an empty folder to a deployed, tested, monitored API: a notes app with users, tags, JWT auth, file uploads, a chat feature, and a Docker/CI pipeline. Every section adds to the same project — nothing here is a disconnected snippet.
What you will have by the end: a REST API with a database, authentication, WebSockets, and an admin dashboard, tested and containerized, deploying through GitHub Actions.
Two things to know before you start:
- Kotlin Tutorial: Complete Series is the prerequisite — you should be comfortable with Kotlin basics first.
- Full source code, organized by stage, is at github.com/kemalcodes/ktor-tutorial.
Why Ktor
Ktor is Kotlin-native, coroutine-based, and modular — you install only the plugins you need instead of getting a batteries-included framework.
// A complete Ktor server
fun main() {
embeddedServer(Netty, port = 8080) {
routing {
get("/hello") { call.respondText("Hello from Ktor!") }
}
}.start(wait = true)
}
No annotations, no XML, no code generation. Every request handler is a coroutine, so suspend functions and Flow work exactly like they do in your app code — no callback hell, no manual thread management.
| Ktor | Spring Boot | Express.js | FastAPI | |
|---|---|---|---|---|
| Language | Kotlin | Java/Kotlin | JavaScript | Python |
| Config | Code DSL | Annotations + YAML | Middleware chain | Decorators |
| Async model | Coroutines | Reactive/Virtual Threads | Event loop | async/await |
| Startup | ~1-2s | ~5-15s | Fast | Fast |
| Memory | ~50 MB | ~200+ MB | Low | Low |
Choose Ktor when you already know Kotlin and want a lightweight API or microservice — especially a backend for a mobile app. Choose Spring Boot if your team is Java-first and wants an enterprise framework with everything built in.
Three companies running Ktor in production: JetBrains itself, Netflix (microservices), and DoorDash (API services). It is a maintained, JetBrains-backed project, not a hobby framework.
If you have used Ktor Client from KMP networking code, this is its server-side sibling — same ContentNegotiation, HttpStatusCode, and ContentType concepts, just receiving requests instead of sending them.
Part 1: Project Setup and Routing
Creating the Project
You need JDK 21+, IntelliJ IDEA, and Gradle. Create build.gradle.kts:
plugins {
kotlin("jvm") version "2.3.0"
kotlin("plugin.serialization") version "2.3.0"
application
}
group = "com.kemalcodes"
version = "0.0.1"
application { mainClass.set("com.kemalcodes.ApplicationKt") }
repositories { mavenCentral() }
val ktorVersion = "3.1.2"
dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("ch.qos.logback:logback-classic:1.5.18")
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter:5.12.2")
}
tasks.test { useJUnitPlatform() }
kotlin { jvmToolchain(21) }
We add more dependencies to this file as the project grows — each section below only shows the new lines.
Entry Point and Plugin Order
// src/main/kotlin/com/kemalcodes/Application.kt
package com.kemalcodes
import com.kemalcodes.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
configureStatusPages() // error handling first — catches errors from everything below
configureSerialization() // JSON before routes use it
configureRouting() // routes last
}
embeddedServer configures everything in code, which is what we use throughout. Ktor’s alternative, EngineMain with a YAML config file, is better once you have multiple environments — dev, staging, production — each needing different settings.
Order matters. Install StatusPages before routing so it can catch errors from every plugin installed after it. As we add plugins (database, auth, CORS), each one gets added to this same list in the right spot — we’ll flag it each time.
plugins/StatusPages.kt gives you real error responses instead of generic 500s:
fun Application.configureStatusPages() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respond(HttpStatusCode.InternalServerError,
ErrorResponse(cause.message ?: "Internal server error", 500))
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ErrorResponse("Not found", 404))
}
}
}
Project layout we’re building toward:
com.kemalcodes/
├── Application.kt // entry point
├── plugins/ // one file per installed plugin
├── routes/ // one file per resource (users, notes, auth...)
├── models/ // @Serializable request/response DTOs
├── db/ // Exposed tables + DatabaseFactory
├── repository/ // database access
└── service/ // business logic (added later, with DI)
Routing
Every route has a method, a path, and a handler:
routing {
route("/api") {
userRoutes()
noteRoutes()
}
}
Extract routes into extension functions on Route, one file per resource — this is the pattern we use for the rest of the guide:
// routes/NoteRoutes.kt
fun Route.noteRoutes() {
route("/notes") {
get { /* list */ }
get("/{id}") { /* one note, via path parameter */ }
post { /* create */ }
put("/{id}") { /* update */ }
delete("/{id}") { /* remove */ }
}
}
Path parameters ({id}) come from the URL and are always strings — validate and convert them:
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
return@get
}
// id is now a safe Int
}
Query parameters (?name=Alex&page=2) handle filtering and pagination:
get {
val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
val size = call.queryParameters["size"]?.toIntOrNull() ?: 10
val nameFilter = call.queryParameters["name"]
// ...
}
Two patterns for handling errors in a handler: return early with an explicit check (shown above — simple, explicit), or throw a custom exception and let StatusPages catch it (cleaner once you have many routes). We use the second pattern from Part 3 onward.
Part 2: JSON
Real APIs exchange JSON, not plain text. Add the plugin:
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true // don't break when clients send extra fields
})
}
}
Mark data classes @Serializable. Use separate request and response models — requests have no id (the server generates it), and a shared ErrorResponse keeps every error the same shape:
@Serializable
data class NoteResponse(val id: Int, val title: String, val content: String,
val userId: Int? = null, val authorName: String? = null, val tags: List<String> = emptyList())
@Serializable
data class CreateNoteRequest(val title: String, val content: String,
val userId: Int? = null, val tags: List<String> = emptyList())
@Serializable
data class UpdateNoteRequest(val title: String? = null, val content: String? = null)
@Serializable
data class ErrorResponse(val message: String, val code: Int)
UpdateNoteRequest’s nullable fields enable partial updates — send only what changed. Read a request with call.receive<T>(), send a response with call.respond():
post {
val request = call.receive<CreateNoteRequest>()
if (request.title.isBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Title required", 400))
return@post
}
val note = repository.create(request)
call.respond(HttpStatusCode.Created, note)
}
Ktor infers the format from the Content-Type/Accept headers, deserializes automatically, and throws if the JSON is malformed (caught by StatusPages). For tests, install ContentNegotiation on the test client too — covered in Part 5.
Part 3: Database — Exposed, CRUD, Relationships, Files, Migrations
Setup
We use Exposed, JetBrains’ SQL library, in DSL mode — every Kotlin call maps directly to SQL. H2 (in-memory) for development, PostgreSQL for production; only the connection URL changes between them.
val exposedVersion = "0.61.0"
dependencies {
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("com.h2database:h2:2.3.232")
// production: implementation("org.postgresql:postgresql:42.7.5")
}
Tables are Kotlin objects:
object Notes : Table("notes") {
val id = integer("id").autoIncrement()
val title = varchar("title", 255)
val content = text("content")
val userId = integer("user_id").references(Users.id).nullable()
override val primaryKey = PrimaryKey(id)
}
Ktor handlers are coroutines, but JDBC calls are blocking. Run every query through a helper that moves the work to Dispatchers.IO so it doesn’t block the coroutine thread pool:
object DatabaseFactory {
fun init() {
val database = Database.connect(
url = "jdbc:h2:mem:ktor_tutorial;DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver", user = "root", password = ""
)
transaction(database) { SchemaUtils.create(Notes, Users) }
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
}
SchemaUtils.create() is fine for a prototype; Part 3’s migrations section replaces it once the schema needs to evolve safely.
Repository Pattern and CRUD
Route handlers shouldn’t contain SQL directly — put database logic behind a repository so handlers stay about HTTP, not persistence:
Route Handler (HTTP) → Repository (queries) → Database
class NoteRepository {
private fun resultRowToNote(row: ResultRow) = NoteResponse(
id = row[Notes.id], title = row[Notes.title], content = row[Notes.content])
suspend fun findAll(page: Int = 1, size: Int = 10): List<NoteResponse> = dbQuery {
Notes.selectAll().orderBy(Notes.id to SortOrder.DESC)
.limit(size).offset(((page - 1) * size).toLong())
.map(::resultRowToNote)
}
suspend fun findById(id: Int): NoteResponse? = dbQuery {
Notes.selectAll().where { Notes.id eq id }.map(::resultRowToNote).singleOrNull()
}
suspend fun create(request: CreateNoteRequest): NoteResponse = dbQuery {
val result = Notes.insert { it[title] = request.title; it[content] = request.content }
resultRowToNote(result.resultedValues!!.first())
}
suspend fun update(id: Int, request: UpdateNoteRequest): NoteResponse? = dbQuery {
val updated = Notes.update({ Notes.id eq id }) {
request.title?.let { v -> it[title] = v }
request.content?.let { v -> it[content] = v }
}
if (updated == 0) null else findById(id)
}
suspend fun delete(id: Int): Boolean = dbQuery { Notes.deleteWhere { Notes.id eq id } > 0 }
}
Every method is suspend and goes through dbQuery. Wire the repository into routes by passing it in — this also makes routes trivial to test with a fake repository later:
fun Route.noteRoutes(repository: NoteRepository) {
route("/notes") {
get {
val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
call.respond(repository.findAll(page))
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
val note = repository.findById(id)
?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Note not found", 404))
call.respond(note)
}
// post / put / delete follow the same shape
}
}
Use real HTTP status codes: 200 for reads, 201 Created for a new resource, 204 No Content for a successful delete, 400 for bad input, 404 for missing resources, 409 Conflict for duplicates (e.g. an email that already exists — check with a repository lookup before inserting).
Relationships
A Notes row can belong to a Users row (one-to-many) and carry multiple tags through a join table (many-to-many):
object Users : Table("users") {
val id = integer("id").autoIncrement()
val name = varchar("name", 100)
val email = varchar("email", 255).uniqueIndex()
override val primaryKey = PrimaryKey(id)
}
object Tags : Table("tags") {
val id = integer("id").autoIncrement()
val name = varchar("name", 50).uniqueIndex()
override val primaryKey = PrimaryKey(id)
}
object NoteTags : Table("note_tags") {
val noteId = integer("note_id").references(Notes.id)
val tagId = integer("tag_id").references(Tags.id)
override val primaryKey = PrimaryKey(noteId, tagId) // composite key
}
.references(Users.id) is a foreign key; .nullable() lets a note exist without an owner. Fetch a note with its author using a join, and its tags with a second join through the pivot table:
Notes.leftJoin(Users).selectAll().map { row ->
NoteResponse(id = row[Notes.id], title = row[Notes.title], content = row[Notes.content],
userId = row.getOrNull(Notes.userId), authorName = row.getOrNull(Users.name))
}
(NoteTags innerJoin Tags).selectAll().where { NoteTags.noteId eq noteId }.map { it[Tags.name] }
leftJoin keeps notes without a user; innerJoin would drop them. When you delete a note, delete its NoteTags rows first or the foreign key constraint fails. For pagination and sorting, combine .limit().offset() with .orderBy(Notes.title to SortOrder.ASC) — all driven by query parameters (?page=1&size=10&sortBy=title&sortOrder=asc).
At scale, watch for the N+1 problem: fetching tags one query per note. Batch it with a single inList query grouped by note ID instead, and add indexes on any column you filter or join on (user_id, note_id, tag_id).
File Uploads
Uploads arrive as multipart/form-data, not JSON — the request has multiple named parts:
post("/upload") {
val multipart = call.receiveMultipart()
var response: UploadResponse? = null
var error: ErrorResponse? = null
multipart.forEachPart { part ->
if (error == null && response == null && part is PartData.FileItem) {
val originalName = part.originalFileName ?: "unknown"
val extension = originalName.substringAfterLast(".", "")
if (extension.lowercase() !in ALLOWED_EXTENSIONS) {
error = ErrorResponse("File type not allowed", 400)
} else {
val bytes = part.streamProvider().readBytes()
if (bytes.size > MAX_FILE_SIZE) {
error = ErrorResponse("File too large", 400)
} else {
// never trust the client's filename — generate your own
val fileName = "${UUID.randomUUID()}.$extension"
File(uploadsDir, fileName).writeBytes(bytes)
response = UploadResponse(fileName, originalName, bytes.size.toLong(), "/api/files/$fileName")
}
}
}
part.dispose()
}
// respond exactly once, after the loop — calling call.respond() inside forEachPart
// and again after it throws ApplicationResponseAlreadySentException
when {
error != null -> call.respond(HttpStatusCode.BadRequest, error!!)
response != null -> call.respond(HttpStatusCode.Created, response!!)
else -> call.respond(HttpStatusCode.BadRequest, ErrorResponse("No file provided", 400))
}
}
Three validation rules that matter: whitelist extensions, cap file size (10 * 1024 * 1024L for 10 MB is a reasonable default), and never use the client’s filename on disk — a random UUID avoids both collisions and path-traversal attacks. Block .. and / in filenames on the download route too:
get("/files/{fileName}") {
val fileName = call.parameters["fileName"] ?: return@get
if (fileName.contains("..") || fileName.contains("/")) {
return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid file name", 400))
}
val file = File(uploadsDir, fileName)
if (!file.exists()) return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("File not found", 404))
call.respondFile(file)
}
Static assets you ship yourself (not uploaded) are simpler — staticResources("/static", "static") serves everything in src/main/resources/static/. For production file storage at any real scale, use S3 or GCS instead of the local disk shown here — local storage doesn’t survive a redeploy or scale past one server.
Migrations
SchemaUtils.create() only creates tables that don’t exist — it can’t add a column or alter a type without losing data. Flyway tracks schema changes as versioned SQL files and applies only the new ones, in order, on every environment.
dependencies { implementation("org.flywaydb:flyway-core:11.8.0") }
Files live in src/main/resources/db/migration/, named V{n}__{description}.sql:
-- V1__create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
);
-- V5__add_password_to_users.sql
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
Run Flyway before Exposed connects, and drop SchemaUtils.create() entirely — Flyway now owns table creation:
fun init() {
Flyway.configure().dataSource(DB_URL, DB_USER, DB_PASSWORD)
.locations("classpath:db/migration").load().migrate()
Database.connect(url = DB_URL, driver = DB_DRIVER, user = DB_USER, password = DB_PASSWORD)
}
The one rule that matters: never edit a migration that has already run anywhere. Flyway hashes each file and fails if an applied one changes. Need to fix a mistake? Write a new migration, don’t touch the old one.
Part 4: Authentication and Security
JWT Auth with Refresh Tokens
We’ll build the version you’d actually ship, not a toy that gets replaced later: short-lived JWT access tokens plus long-lived, revocable refresh tokens.
dependencies {
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
implementation("at.favre.lib:bcrypt:0.10.2") // password hashing
}
-- V6__add_role_and_refresh_tokens.sql
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
token VARCHAR(500) NOT NULL UNIQUE,
expires_at VARCHAR(50) NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE
);
Password hashing. Never store plain text. bcrypt is slow on purpose (cost factor 12 is a good default) and salts automatically, so identical passwords produce different hashes:
val hashedPassword = BCrypt.withDefaults().hashToString(12, password.toCharArray())
val result = BCrypt.verifyer().verify(password.toCharArray(), storedHash)
// result.verified == true if correct
Token design. The access token is a signed JWT carrying userId, email, and role — the server can check permissions without a database hit, but it can’t be revoked once issued, so it expires in an hour. The refresh token is a random UUID with no payload; it lives in the database (so it can be revoked) for 30 days:
object JwtConfig {
const val SECRET = "..." // System.getenv("JWT_SECRET") in production, never hardcoded
const val ISSUER = "ktor-tutorial"
const val AUDIENCE = "ktor-tutorial-api"
const val ACCESS_TOKEN_EXPIRATION_MS = 3_600_000L // 1 hour
const val REFRESH_TOKEN_EXPIRATION_MS = 2_592_000_000L // 30 days
fun generateAccessToken(userId: Int, email: String, role: String): String =
JWT.create().withAudience(AUDIENCE).withIssuer(ISSUER)
.withClaim("userId", userId).withClaim("email", email).withClaim("role", role)
.withExpiresAt(Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_MS))
.sign(Algorithm.HMAC256(SECRET))
fun generateRefreshToken(): String = UUID.randomUUID().toString()
}
Install the Authentication plugin so authenticate("auth-jwt") { } can gate any route:
install(Authentication) {
jwt("auth-jwt") {
realm = "ktor-tutorial"
verifier(JWT.require(Algorithm.HMAC256(JwtConfig.SECRET))
.withAudience(JwtConfig.AUDIENCE).withIssuer(JwtConfig.ISSUER).build())
validate { credential ->
if (credential.payload.getClaim("userId").asInt() != null) JWTPrincipal(credential.payload) else null
}
challenge { _, _ -> call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Token is invalid or expired", 401)) }
}
}
The four endpoints, with the details that actually matter in production. issueTokens is a small shared helper — sign an access token, generate a refresh token, persist the refresh token, return both:
suspend fun issueTokens(user: UserResponse, tokens: RefreshTokenRepository): TokenResponse {
val access = JwtConfig.generateAccessToken(user.id, user.email, user.role)
val refresh = JwtConfig.generateRefreshToken()
tokens.create(user.id, refresh)
return TokenResponse(access, refresh)
}
fun Route.authRoutes(users: UserRepository, tokens: RefreshTokenRepository) {
post("/auth/register") {
val r = call.receive<RegisterRequest>()
// validate: name >= 2 chars, email matches a regex, password >= 8 chars with a digit
if (users.findByEmail(r.email) != null)
return@post call.respond(HttpStatusCode.Conflict, ErrorResponse("Email already registered", 409))
val user = users.register(r) // hashes password internally
call.respond(HttpStatusCode.Created, issueTokens(user, tokens))
}
post("/auth/login") {
val r = call.receive<LoginRequest>()
// same generic error for "no such user" and "wrong password" — don't leak which emails exist
val user = users.verifyPassword(r.email, r.password)
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid email or password", 401))
call.respond(issueTokens(user, tokens))
}
post("/auth/refresh") {
val r = call.receive<RefreshTokenRequest>()
val info = tokens.findValidToken(r.refreshToken)
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid or expired refresh token", 401))
tokens.revoke(r.refreshToken) // rotation: old token can never be reused
val user = users.findById(info.userId)!!
call.respond(issueTokens(user, tokens))
}
authenticate("auth-jwt") {
get("/auth/me") {
val userId = call.principal<JWTPrincipal>()!!.payload.getClaim("userId").asInt()
val user = users.findById(userId)
?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("User not found", 404))
call.respond(user)
}
post("/auth/logout") {
val userId = call.principal<JWTPrincipal>()!!.payload.getClaim("userId").asInt()
tokens.revokeAllForUser(userId)
call.respond(mapOf("message" to "Logged out successfully"))
}
}
}
Token rotation is the detail that separates a real implementation from a tutorial toy: every refresh call revokes the token it was given and issues a fresh pair. If a stolen refresh token gets used, the legitimate user’s next refresh fails — that failure is your breach signal. Logout revokes every refresh token for that user; the access token they were holding still works until it naturally expires (up to an hour), which is the accepted tradeoff for not needing a token blocklist.
Protect any route with authenticate("auth-jwt") { }, and check ownership inside the handler for per-resource access control:
authenticate("auth-jwt") {
delete("/notes/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
val userId = call.principal<JWTPrincipal>()!!.payload.getClaim("userId").asInt()
val note = repository.findById(id)
?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Note not found", 404))
if (note.userId != userId)
return@delete call.respond(HttpStatusCode.Forbidden, ErrorResponse("Not your note", 403))
repository.delete(id); call.respond(HttpStatusCode.NoContent)
}
}
CORS, Rate Limiting, Security Headers
Three plugins close the gaps auth alone doesn’t cover:
dependencies {
implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-rate-limit:$ktorVersion")
implementation("io.ktor:ktor-server-default-headers:$ktorVersion")
}
CORS decides which websites your API answers. anyHost() is fine for local development only — in production, name your actual frontend domain:
install(CORS) {
allowMethod(HttpMethod.Post); allowMethod(HttpMethod.Put); allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.Authorization); allowHeader(HttpHeaders.ContentType)
allowCredentials = true
allowHost("app.example.com", schemes = listOf("https")) // production
// anyHost() // development only — never combine with allowCredentials in production
}
Rate limiting stops brute force. Give auth endpoints a much tighter limit than general API traffic:
install(RateLimit) {
global { rateLimiter(limit = 60, refillPeriod = 1.minutes) }
register(RateLimitName("auth")) { rateLimiter(limit = 10, refillPeriod = 1.minutes) }
}
Security headers stop clickjacking, MIME sniffing, and XSS at the browser:
install(DefaultHeaders) {
header("X-Frame-Options", "DENY")
header("X-Content-Type-Options", "nosniff")
header("Content-Security-Policy", "default-src 'self'")
header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
SQL injection is not a real risk here as long as you stick to Exposed’s DSL (Users.selectAll().where { Users.email eq email }) — it parameterizes automatically. The risk only appears if you drop to raw SQL with string interpolation, which this guide never does.
Part 5: WebSockets and Server-Rendered HTML
Real-Time Chat with WebSockets
HTTP is request-response; WebSockets are a persistent, two-way connection — use them for chat, live notifications, or collaborative editing.
dependencies { implementation("io.ktor:ktor-server-websockets:$ktorVersion") }
install(WebSockets) {
pingPeriod = 15.seconds; timeout = 15.seconds // heartbeat detects dead connections
}
A thread-safe room registry, keyed by room name:
object ChatRoom {
private val rooms = ConcurrentHashMap<String, MutableSet<ChatConnection>>()
fun join(c: ChatConnection) = rooms.getOrPut(c.room) { ConcurrentHashMap.newKeySet() }.add(c)
fun leave(c: ChatConnection) { rooms[c.room]?.remove(c) }
suspend fun broadcast(room: String, message: ChatMessage) {
val json = Json.encodeToString(message)
rooms[room]?.forEach { c ->
try { c.session.send(json) } catch (e: Exception) { leave(c) } // drop dead connections
}
}
}
The route joins a room, echoes every incoming frame to everyone in it, and cleans up on disconnect:
webSocket("/ws/chat/{room}") {
val room = call.parameters["room"] ?: "general"
val username = call.request.queryParameters["username"] ?: "Anonymous"
val connection = ChatConnection(this, username, room)
ChatRoom.join(connection)
ChatRoom.broadcast(room, ChatMessage("System", "$username joined", room))
try {
for (frame in incoming) {
if (frame is Frame.Text) ChatRoom.broadcast(room, ChatMessage(username, frame.readText(), room))
}
} finally {
ChatRoom.leave(connection)
ChatRoom.broadcast(room, ChatMessage("System", "$username left", room))
}
}
Authenticate a WebSocket by passing the JWT as a query parameter and verifying it manually on connect (the Authentication plugin’s authenticate {} block doesn’t wrap webSocket {} routes). If you only need server-to-client updates — no chat, just notifications — Server-Sent Events (ContentType.Text.EventStream) are a simpler alternative to a full WebSocket.
Server-Rendered Admin Pages with HTMX
Not everything needs a JavaScript frontend. For internal tools, HTMX adds dynamic behavior with HTML attributes — the server returns HTML fragments, and HTMX swaps them into the page.
dependencies {
implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
}
get("/notes") {
call.respondHtml {
body {
noteRepository.findAll().forEach { note -> noteCard(note) }
}
}
}
private fun BODY.noteCard(note: NoteResponse) {
div("note-card") {
id = "note-${note.id}"
h3 { +note.title }; p { +note.content }
button {
attributes["hx-delete"] = "/admin/notes/${note.id}"
attributes["hx-target"] = "#note-${note.id}"
attributes["hx-swap"] = "outerHTML"
+"Delete"
}
}
}
hx-delete fires a DELETE request when clicked; the response (empty, in this case) replaces the targeted element — deleting a note deletes its card, with zero JavaScript written. Reach for HTMX on admin dashboards and internal tools where you want to ship fast; reach for React/Vue when you need complex client-side state or offline support; reach for a native mobile app when you need camera access or push notifications.
Part 6: Production — DI, Testing, Docker, CI/CD
Dependency Injection with Koin
As routes, repositories, and now a service layer accumulate, manually constructing and threading every dependency through function parameters gets unwieldy. Koin is a lightweight DI framework with an official Ktor plugin and a Kotlin DSL (no annotations, no code generation).
dependencies {
implementation("io.insert-koin:koin-ktor:4.0.3")
implementation("io.insert-koin:koin-logger-slf4j:4.0.3")
}
Introduce a thin service layer between routes and repositories — this is where business logic (validation, authorization, orchestration across repositories) accumulates as the app grows:
class AuthService(private val users: UserRepository, private val tokens: RefreshTokenRepository) {
suspend fun login(email: String, password: String): TokenResponse? {
val user = users.verifyPassword(email, password) ?: return null
return issueTokens(user, tokens)
}
// register, refresh, logout follow the same shape
}
Declare every dependency once, in a module:
val appModule = module {
single { UserRepository() }
single { NoteRepository() }
single { RefreshTokenRepository() }
single { AuthService(get(), get()) } // get() resolves each constructor arg by type
single { NoteService(get()) }
}
fun Application.configureDI() {
install(Koin) { slf4jLogger(); modules(appModule) }
}
single { } creates one instance for the app’s lifetime — correct for stateless repositories and services. configureDI() must run first in Application.module(), before anything that injects. Routes pull dependencies with a lazy delegate instead of constructing them:
fun Application.configureRouting() {
val noteRepository by inject<NoteRepository>()
val userRepository by inject<UserRepository>()
routing {
route("/api") { noteRoutes(noteRepository); userRoutes(userRepository) }
}
}
The payoff isn’t visible in a small app — it shows up once you have 20+ services, want to swap a real repository for a fake one in tests, or add a new dependency without touching every call site that needs it.
Testing
Ktor’s testApplication runs your app in memory — no real HTTP server, no network, fast and isolated:
dependencies {
testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
testImplementation("io.ktor:ktor-client-websockets:$ktorVersion")
}
private fun ApplicationTestBuilder.jsonClient() = createClient { install(ContentNegotiation) { json() } }
One integration test can cover an entire flow — register, login, hit a protected route, refresh (and confirm the old refresh token is now dead), then logout:
@Test
fun `complete auth flow`() = testApplication {
application { module() }
val client = jsonClient()
val tokens = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Sam", "sam@example.com", "password123"))
}.body<TokenResponse>()
val profile = client.get("/api/auth/me") { bearerAuth(tokens.accessToken) }
assertEquals(HttpStatusCode.OK, profile.status)
val refreshed = client.post("/api/auth/refresh") {
contentType(ContentType.Application.Json); setBody(RefreshTokenRequest(tokens.refreshToken))
}.body<TokenResponse>()
// reusing the old refresh token must now fail — this is what proves rotation works
val reused = client.post("/api/auth/refresh") {
contentType(ContentType.Application.Json); setBody(RefreshTokenRequest(tokens.refreshToken))
}
assertEquals(HttpStatusCode.Unauthorized, reused.status)
}
WebSocket routes get their own client:
@Test
fun `chat broadcasts messages`() = testApplication {
application { module() }
createClient { install(WebSockets) }.webSocket("/ws/chat/lobby?username=Alex") {
incoming.receive() // join message
send("Hello!")
val text = (incoming.receive() as Frame.Text).readText()
assertTrue(text.contains("Hello!"))
}
}
Each testApplication block gets a fresh H2 database — tests never leak state into each other, and there’s no cleanup step to remember. For every endpoint, test the happy path, missing/invalid auth, validation failures, not-found, and edge cases (empty lists, pagination boundaries). Name tests as behavior descriptions (`create note with blank title returns 400`, not `test create`) — a failing test name should tell you what broke without opening the file.
Docker
Add a /health route before any of this — Docker’s HEALTHCHECK below and the CI workflow after it both poll this path, and there’s no route for it yet:
routing { get("/health") { call.respond(HttpStatusCode.OK, mapOf("status" to "UP")) } }
Package the app as a fat JAR, then build a small runtime image around it:
tasks.register<Jar>("buildFatJar") {
archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes["Main-Class"] = "com.kemalcodes.ApplicationKt" }
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
with(tasks.jar.get())
}
# Stage 1: build with the full Gradle+JDK image
FROM gradle:8.12-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts gradle.properties ./
COPY src ./src
RUN gradle buildFatJar --no-daemon
# Stage 2: run on a minimal JRE image — ~150MB instead of ~500MB
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S ktor && adduser -S ktor -G ktor
COPY --from=build /app/build/libs/*-all.jar app.jar
RUN mkdir -p /app/uploads && chown -R ktor:ktor /app
USER ktor
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
The multi-stage build keeps the final image small; the non-root user limits blast radius if the app is ever exploited; -XX:MaxRAMPercentage stops the JVM from ignoring the container’s memory limit and getting OOM-killed. Don’t forget a .dockerignore (.git, build, .gradle, .idea) or the build context balloons.
Docker Compose adds PostgreSQL for a production-like local setup:
services:
app:
build: .
ports: ["8080:8080"]
environment:
- DB_URL=jdbc:postgresql://db:5432/ktor_tutorial
- JWT_SECRET=change-this-in-production
depends_on:
db: { condition: service_healthy }
db:
image: postgres:17-alpine
environment: { POSTGRES_DB: ktor_tutorial, POSTGRES_USER: ktor, POSTGRES_PASSWORD: ktor_password }
volumes: ["postgres_data:/var/lib/postgresql/data"]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U ktor"], interval: 5s, retries: 5 }
volumes: { postgres_data: {} }
depends_on: condition: service_healthy stops the app container starting before Postgres is actually ready. The named volume keeps data across restarts — without it, docker compose down wipes your database. Read the database URL from an environment variable with an H2 fallback (System.getenv("DB_URL") ?: "jdbc:h2:mem:test;...") and the same code runs unchanged locally and in Docker.
CI/CD and Monitoring
A GitHub Actions workflow that tests every push and builds a Docker image on main:
# .github/workflows/ci.yml
name: CI
on:
push: { branches: [main] }
pull_request: { branches: [main] }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
- run: ./gradlew test
- run: ./gradlew buildFatJar
docker:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t ktor-tutorial .
- run: |
docker run -d --name t -p 8080:8080 ktor-tutorial
sleep 5 && curl -f http://localhost:8080/health || exit 1
Gradle caching (keyed on build.gradle.kts) saves a minute or two per run. The Docker job only runs on main, after tests pass, and smoke-tests the image against /health before calling it done. Store secrets (JWT_SECRET, DB_PASSWORD, deploy keys) in GitHub Actions secrets, never in the workflow file — reference them as ${{ secrets.JWT_SECRET }}.
For production visibility, expose Prometheus metrics — request counts, latency, JVM memory — with zero per-route code:
dependencies {
implementation("io.ktor:ktor-server-metrics-micrometer:$ktorVersion")
implementation("io.micrometer:micrometer-registry-prometheus:1.14.5")
}
fun Application.configureMonitoring() {
val registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
install(MicrometerMetrics) { registry = registry }
routing { get("/metrics") { call.respondText(registry.scrape()) } }
}
Point Prometheus at /metrics and Grafana at Prometheus for dashboards — the plugin instruments every request automatically. For deployment, a VPS with Docker Compose gives full control at a predictable cost; Railway or Fly.io trade some of that control for zero server management and push-to-deploy. In front of any of them, put Nginx (or Caddy) for TLS termination — Ktor stays focused on application logic and never needs to know about certificates.
Production Checklist
Before shipping this (or your own Ktor API) to real users:
- Secrets (
JWT_SECRET,DB_PASSWORD, OAuth keys) come from environment variables, never source code - Passwords hashed with bcrypt; generic error message for both “no such user” and “wrong password”
- Refresh tokens use rotation; logout revokes them
- CORS allows only real frontend domains — no
anyHost()outside development - Rate limiting on
/auth/*, tighter than general API traffic - Security headers set (
X-Frame-Options, CSP, HSTS) - Migrations (Flyway), not
SchemaUtils.create(), own the schema - Every endpoint tested: happy path, auth failure, validation failure, not-found
- Docker image: multi-stage build, non-root user, health check,
.dockerignore - CI runs tests before every deploy; deploy only happens after they pass
-
/metricsexposed and something is actually watching it
Where to Go From Here
You now have a tested, containerized Ktor API with a database, auth, real-time features, and a deploy pipeline — the same shape as a production service, minus the scale. A few natural next steps, each with its own dedicated guide:
- Ktor vs Spring Boot — a deeper framework comparison if you’re deciding between them for a team
- OAuth 2.0 with Google Sign-In — add social login on top of the JWT auth built here
- OpenAPI and Swagger UI — generate interactive API docs from your routes
- Full-Stack Kotlin: Ktor + KMP — connect this backend to a Kotlin Multiplatform client with shared models
The complete, working code for every part of this guide is on GitHub: github.com/kemalcodes/ktor-tutorial.