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:

  • application plugin — Lets you run the server with ./gradlew run
  • mainClass — Points to the file containing your main() function
  • Ktor dependenciesktor-server-core and ktor-server-netty are 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:

  1. Install plugins (serialization, authentication, etc.)
  2. Configure routes
  3. 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

PluginWhat It Does
ContentNegotiationSerializes/deserializes request and response bodies
StatusPagesHandles errors and returns proper HTTP status codes
AuthenticationProtects routes with JWT, OAuth, Basic Auth
CORSAllows cross-origin requests from browsers
CallLoggingLogs every request
DefaultHeadersAdds default headers to every response
CompressionCompresses 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:

  1. Creates an in-memory server (no real HTTP server starts)
  2. Loads your application module
  3. Provides a test client to make requests
  4. 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?

  1. StatusPages first — Catches errors from all other plugins
  2. Serialization — Needs to be installed before routes use JSON
  3. Authentication — Needs to be installed before protected routes
  4. 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