In the previous tutorials, we learned what Ktor is and how it compares to Spring Boot. Now it is time to build a real project.
We will set up a proper Ktor application with the right project structure, plugins, error handling, and configuration. This is the foundation for everything we build in this series.
Project Structure
Here is the project structure we will create:
ktor-tutorial/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
├── src/
│ ├── main/
│ │ ├── kotlin/
│ │ │ └── com/kemalcodes/
│ │ │ ├── Application.kt
│ │ │ └── plugins/
│ │ │ ├── Routing.kt
│ │ │ └── StatusPages.kt
│ │ └── resources/
│ │ └── logback.xml
│ └── test/
│ └── kotlin/
│ └── com/kemalcodes/
│ └── ApplicationTest.kt
This follows the standard Kotlin/Gradle layout. Source code goes in src/main/kotlin, resources in src/main/resources, and tests in src/test/kotlin.
Build Configuration
Let’s start with 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 {
// Ktor Server Core
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
// Plugins
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
// JSON Serialization
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
// Logging
implementation("ch.qos.logback:logback-classic:1.5.18")
// Testing
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)
}
Key parts:
applicationplugin — Lets you run the server with./gradlew runmainClass— Points to the file containing yourmain()function- Ktor dependencies —
ktor-server-coreandktor-server-nettyare the minimum ktor-server-test-host— For testing without starting a real server- JUnit 5 — Modern testing framework
The Application Entry Point
Create src/main/kotlin/com/kemalcodes/Application.kt:
package com.kemalcodes
import com.kemalcodes.plugins.configureRouting
import com.kemalcodes.plugins.configureStatusPages
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
fun main() {
embeddedServer(
factory = Netty,
port = 8080,
host = "0.0.0.0",
module = Application::module
).start(wait = true)
}
fun Application.module() {
configureStatusPages()
configureRouting()
}
embeddedServer vs EngineMain
Ktor has two ways to start a server:
embeddedServer — You configure everything in code:
embeddedServer(Netty, port = 8080) {
module()
}.start(wait = true)
EngineMain — Configuration comes from a YAML or HOCON file:
// application.yaml
ktor:
deployment:
port: 8080
application:
modules:
- com.kemalcodes.ApplicationKt.module
We use embeddedServer in this series because it is simpler and everything is in one place. For production apps with multiple environments (dev, staging, production), EngineMain with YAML config is better.
Application Modules
The Application.module() function is where you set up everything. Think of it as the “entry point” for your application logic. Here you:
- Install plugins (serialization, authentication, etc.)
- Configure routes
- Set up error handling
The order matters. Install error handling before routes so errors in routes get caught.
Plugins
Ktor uses a plugin system. Each feature is a separate plugin you install. This keeps your application lightweight — you only include what you need.
How Plugins Work
A plugin is installed using the install() function:
fun Application.module() {
install(ContentNegotiation) {
json()
}
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText("Error: ${cause.message}")
}
}
}
Each plugin has its own configuration block. You configure it when you install it.
Common Plugins
| Plugin | What It Does |
|---|---|
ContentNegotiation | Serializes/deserializes request and response bodies |
StatusPages | Handles errors and returns proper HTTP status codes |
Authentication | Protects routes with JWT, OAuth, Basic Auth |
CORS | Allows cross-origin requests from browsers |
CallLogging | Logs every request |
DefaultHeaders | Adds default headers to every response |
Compression | Compresses responses (gzip, deflate) |
We will use these plugins throughout the series.
Error Handling with StatusPages
Good error handling is important. Without it, your server returns generic 500 errors that don’t help anyone.
Create src/main/kotlin/com/kemalcodes/plugins/StatusPages.kt:
package com.kemalcodes.plugins
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
fun Application.configureStatusPages() {
install(StatusPages) {
// Handle generic exceptions
exception<Throwable> { call, cause ->
call.respondText(
text = "500: ${cause.message}",
status = HttpStatusCode.InternalServerError
)
}
// Handle 404 Not Found
status(HttpStatusCode.NotFound) { call, status ->
call.respondText(
text = "404: Page Not Found",
status = status
)
}
}
}
The StatusPages plugin lets you:
- Handle exceptions — Catch specific exception types and return proper error responses
- Handle status codes — Customize responses for 404, 403, etc.
We will expand this in later tutorials to handle validation errors and custom exceptions.
Routing
Create src/main/kotlin/com/kemalcodes/plugins/Routing.kt:
package com.kemalcodes.plugins
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
get("/health") {
call.respondText("OK")
}
get("/api/info") {
call.respondText("Ktor Tutorial API v0.0.1")
}
}
}
We extract routing into a separate function. This keeps Application.module() clean and makes it easy to organize routes as the project grows.
Logging
Create src/main/resources/logback.xml:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
Ktor uses SLF4J with Logback for logging. You can use the logger anywhere in your application:
private val logger = LoggerFactory.getLogger("Application")
get("/") {
logger.info("Root endpoint called")
call.respondText("Hello, Ktor!")
}
Running the Server
Start the server:
./gradlew run
You should see:
2026-07-08 10:00:00.000 [main] INFO ktor.application - Responding at http://0.0.0.0:8080
Test the endpoints:
curl http://localhost:8080/
# Hello, Ktor!
curl http://localhost:8080/health
# OK
curl http://localhost:8080/api/info
# Ktor Tutorial API v0.0.1
curl http://localhost:8080/unknown
# 404: Page Not Found
All four work as expected.
Testing
Create src/test/kotlin/com/kemalcodes/ApplicationTest.kt:
package com.kemalcodes
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContains
class ApplicationTest {
@Test
fun `root endpoint returns hello`() = testApplication {
application { module() }
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello, Ktor!", response.bodyAsText())
}
@Test
fun `health endpoint returns OK`() = testApplication {
application { module() }
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("OK", response.bodyAsText())
}
@Test
fun `api info endpoint returns version`() = testApplication {
application { module() }
val response = client.get("/api/info")
assertEquals(HttpStatusCode.OK, response.status)
assertContains(response.bodyAsText(), "v0.0.1")
}
@Test
fun `unknown route returns 404`() = testApplication {
application { module() }
val response = client.get("/unknown")
assertEquals(HttpStatusCode.NotFound, response.status)
assertContains(response.bodyAsText(), "404")
}
}
Run the tests:
./gradlew test
How testApplication Works
testApplication is Ktor’s test utility. It:
- Creates an in-memory server (no real HTTP server starts)
- Loads your application module
- Provides a test
clientto make requests - Returns responses you can assert on
Tests run fast because there is no network involved. Everything happens in memory.
Understanding the Application Module
The Application.module() function is the heart of your Ktor application. Everything happens here. Let’s look at why the order matters:
fun Application.module() {
configureStatusPages() // 1. Error handling first
configureSerialization() // 2. Then JSON support
configureAuthentication() // 3. Then auth (in later tutorials)
configureRouting() // 4. Routes last
}
Why this order?
- StatusPages first — Catches errors from all other plugins
- Serialization — Needs to be installed before routes use JSON
- Authentication — Needs to be installed before protected routes
- Routing — Uses all the plugins above
If you install StatusPages after routing, errors in routes will not be caught. If you install serialization after routes, JSON responses will not work.
Hot Reload
During development, you do not want to restart the server every time you change code. Ktor supports hot reload:
./gradlew run -t
The -t flag enables continuous build. When you save a file, Gradle recompiles and Ktor reloads the application module.
For embeddedServer, add development mode:
embeddedServer(Netty, port = 8080) {
// Development mode enables auto-reload
developmentMode = true
module()
}.start(wait = true)
Environment Configuration
For different environments (dev, staging, production), you can use environment variables:
fun main() {
val port = System.getenv("PORT")?.toIntOrNull() ?: 8080
val host = System.getenv("HOST") ?: "0.0.0.0"
embeddedServer(Netty, port = port, host = host) {
module()
}.start(wait = true)
}
Run with custom settings:
PORT=9090 HOST=localhost ./gradlew run
Project Organization Tips
As your project grows, follow these patterns:
1. One Plugin Per File
plugins/
├── Routing.kt
├── StatusPages.kt
├── Serialization.kt
├── Authentication.kt
└── Database.kt
Each file configures one plugin. This keeps things organized.
2. Group Routes by Feature
routes/
├── UserRoutes.kt
├── NoteRoutes.kt
└── AuthRoutes.kt
Each file defines routes for one resource. We will do this in the routing tutorial.
3. Separate Concerns
com.kemalcodes/
├── Application.kt // Entry point
├── plugins/ // Plugin configuration
├── routes/ // Route definitions
├── models/ // Data classes
├── repositories/ // Database access
└── services/ // Business logic
This is the structure we will build throughout this series.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-03-setup
What’s Next?
In the next tutorial, we will dive deep into routing. You will learn how to handle GET, POST, PUT, DELETE requests, use path parameters and query parameters, and organize routes cleanly.
Ktor Tutorial #4: Routing — Handling HTTP Requests
Related Articles
- Ktor Tutorial #2: Ktor vs Spring Boot — Framework comparison
- Ktor Tutorial #1: What is Ktor? — Introduction to Ktor
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch