В этой статье мы разберём, как написать собственный сервер на Kotlin с его сахарным, удобным синтаксисом, подключить базу данных, создать несколько эндпоинтов, а затем всего за пару минут захостить и сервер, и базу. В итоге у нас получится полноценная связка «сервер + БД», готовая к использованию в реальном проекте.
Для начала нам понадобится среда разработки. Я буду работать в IntelliJ IDEA Ultimate, но если у вас Community-версия — не проблема. В этом случае вы можете сгенерировать Ktor-проект прямо на сайте Ktor.
Если же вы используете Ultimate-версию — можете создать сервер прямо из IDE. В любом случае, повторяйте за мной и установите необходимые библиотеки, которые мы будем использовать далее.

com.ваш_выбор или com.вашеимя.
GET /, GET /hello/{name} и POST /echo.

Мы будем работать с базой данных PostgreSQL, а для выполнения запросов и взаимодействия с БД воспользуемся фреймворком Exposed — удобным DSL от JetBrains.
Когда всё настроено, можно начинать. Нажимаем Create, ждём генерацию проекта, затем синхронизируем наш Gradle и запускаем файл Application.kt.

В дальнейшем наш сервер будет запускать все наши маршруты (или endpoints) внутри функции Application.module() { ... }.
А теперь переходим к самому интересному — работе с базой данных. Наша БД будет уже задеплоена на сервере. Я использую сервис Railway, поэтому покажу процесс на его примере. Если у вас есть свой хостинг или провайдер — используйте его. Я работаю с Railway уже почти 3 года, написал на нём кучу бизнес-решений, и он ни разу меня не подвёл.
Переходим к созданию базы: New → Database → PostgreSQL

Работу с Redis и JWT я разберу в отдельной статье — там мы поговорим о кешировании, уменьшении нагрузки на базу данных и правильной работе с токенами. В этой же статье сосредоточимся только на сервере и PostgreSQL, чтобы не перегружать материал.

Дальше идём в Variables и спускаемся вниз. Здесь мы будем хранить данные, необходимые для подключения нашего сервера к базе данных, чтобы он мог работать с ней и выполнять запросы.

Теперь переходим на сервер и открываем файл Databases.kt. В нём вы найдёте мой код с комментариями, которые помогут понять, что и как нужно делать.
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction object DatabaseConfig { fun init(): Boolean { return try { Database.connect( url = "jdbc:postgresql://caboose.proxy.rlwy.net:19083/railway", // JDBC-ссылка к БД Railway (домен caboose.proxy.rlwy.net, порт 19083) // Данные берутся из переменных окружения RAILWAY_TCP_PROXY_DOMAIN и RAILWAY_TCP_PROXY_PORT driver = "org.postgresql.Driver", // драйвер PostgreSQL user = "postgres", // PGUSER -> postgres password = "свой" // пароль из PGPASSWORD ) transaction { // Здесь можно создавать или инициализировать таблицы } println("✅ Подключение к БД успешно!") true } catch (e: Exception) { println("❌ Ошибка подключения к БД: ${e.message}") false } } }
Если всё настроено правильно, перед запуском сервера нужно предварительно инициализировать базу данных. После этого можно запускать сервер.

Дальше будем создавать таблицы и инициализировать их в нашей базе данных.
Мы уже подключили библиотеку Exposed, теперь создадим файл Tables.kt. В нём будет наш код, как описывать таблицы с помощью Exposed. Пока сосредоточимся на таблице Users, чтобы работать с ней в дальнейшем.
// 1) Автоинкрементный integer primary key object Users : Table("users") { val id = integer("id").autoIncrement() // INT AUTO_INCREMENT val username = varchar("username", 50).uniqueIndex() val email = varchar("email", 255).nullable() override val primaryKey = PrimaryKey(id) // явно, но autoIncrement уже даёт PK } // 2) UUID primary key (если хочешь распределённые id) object Sessions : Table("sessions") { val id = uuid("id").clientDefault { UUID.randomUUID() } // UUID default val userId = reference("user_id", Users.id, onDelete = ReferenceOption.CASCADE) val token = varchar("token", 128).uniqueIndex() override val primaryKey = PrimaryKey(id) } // 3) Composite primary key (например, связь many-to-many) object UserRoles : Table("user_roles") { val userId = reference("user_id", Users.id, onDelete = ReferenceOption.CASCADE) val role = varchar("role", 50) override val primaryKey = PrimaryKey(userId, role, name = "PK_UserRole") } // 4) Таблица с внешним ключом и разными типами колонок object Orders : Table("orders") { val id = long("id").autoIncrement() // long для больших чисел val userId = reference("user_id", Users.id) val total = decimal("total", precision = 10, scale = 2).default(0.toBigDecimal()) val status = varchar("status", 20).default("NEW") // можно хранить enum как string val notes = text("notes").nullable() override val primaryKey = PrimaryKey(id) } // 5) Пример enum-like с проверкой (можно хранить int или string) enum class CandyType { CHOCOLATE, JELLY, CARAMEL } object Candies : Table("candies") { val id = integer("id").autoIncrement() val name = varchar("name", 100) val type = varchar("type", 20).default(CandyType.CHOCOLATE.name) // store enum name override val primaryKey = PrimaryKey(id) }
Инициализируем её в файле Databases.kt — после сборки и запуска сервера таблица автоматически создастся в базе данных.
transaction { // Здесь можно создавать или инициализировать таблицы SchemaUtils.create(Users) }
Здесь мы создаём модель данных, с которой будем работать. Она нужна, чтобы корректно оформлять тело запроса (body) и обрабатывать данные в нашем сервере.
Модель данных можно разместить в отдельном файле Serialization.kt — так код будет аккуратным и логично структурированным.
@Serializable data class CreateUserRequest( val username: String, val email: String? = null ) @Serializable data class UserDTO( val id: Int, val username: String, val email: String? = null )
В файле мы будем писать Routing. Создадим два маршрута: один для добавления пользователя, второй — для получения всех пользователей.
fun Application.configureRouting() { routing { post("/users") { // Получаем тело запроса и десериализуем его в объект CreateUserRequest val request = call.receive<CreateUserRequest>() // Открываем транзакцию Exposed для работы с базой данных val userId = transaction { // Вставляем нового пользователя в таблицу Users Users.insert { it[username] = request.username // записываем имя пользователя it[email] = request.email // записываем email (может быть null) } get Users.id // получаем id только что вставленной записи } // Отправляем клиенту ответ 201 Created с JSON, содержащим id нового пользователя call.respond(HttpStatusCode.Created, mapOf("id" to userId)) } // Обрабатываем GET-запрос по пути "/users" get("/users") { // Открываем транзакцию Exposed для работы с базой данных val all = transaction { // Получаем все записи из таблицы Users Users.selectAll().map { row -> // Преобразуем каждую запись в объект UserDTO UserDTO( id = row[Users.id], // id пользователя (Int) username = row[Users.username],// имя пользователя (String) email = row[Users.email] // email пользователя (String?), может быть ) } } // Отправляем клиенту результат в виде JSON // Здесь используется kotlinx.serialization для сериализации списка UserDTO call.respond(all) } } }
Не забудем добавить это в наш Application.kt, чтобы маршруты заработали.
fun Application.module() { DatabaseConfig.init() // БД configureRouting() // POST и GET }
После того как маршруты настроены, мы можем протестировать запросы в Postman.

Погнали деплоить!
Мы будем использовать Railway (или любой другой сервис на ваш выбор), но я покажу пример именно на Railway.
Я работаю с этим сервисом уже 3 года, пишу бэкенд и Android-приложения, и мне всегда было просто быстро развернуть проект: написать код, обновить и сразу увидеть его работающим в интернете — без сложных настроек, «из коробки».
Railway — это суперудобно. В дальнейшем мы сможем подключать к нему другие сервисы: Redis, Kafka и многое другое. Всё делается буквально в два клика. Сообщество отличное, есть поддержка, и особенно мне нравится встроенная метрика: можно смотреть логи и анализировать работу сервиса.
Для старта дают 5$, что достаточно, чтобы потыкать и протестировать проект. В дальнейшем можно перейти на план Hobby. [Реклама удалена модератором]
Погнали внутрь проекта. В папке Gradle обратите внимание на поле group. У меня оно выглядит так:
group = "com.ilya"
У вас может быть другое, например:
group = "com.ivan"
Делайте так, как в моём примере Gradle:
val exposed_version: String by project val h2_version: String by project val kotlin_version: String by project val logback_version: String by project val postgres_version: String by project plugins { kotlin("jvm") version "2.2.20" id("io.ktor.plugin") version "3.3.2" id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" } group = "com.ilya" version = "0.0.1" application { mainClass = "io.ktor.server.netty.EngineMain" } application { mainClass.set("io.ktor.server.netty.EngineMain") val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") } tasks { named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") { archiveFileName.set("app.jar") mergeServiceFiles() } } tasks { shadowJar { archiveFileName.set("app.jar") mergeServiceFiles() } } application { mainClass.set("com.ilya.ApplicationKt") // Убедитесь, что это правильная точка входа } tasks { create("stage").dependsOn("installDist") } tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> { manifest { attributes["Main-Class"] = "com.ilya.ApplicationKt" // Точка входа } mergeServiceFiles() archiveClassifier.set("") } tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> { archiveFileName.set("server.jar") // Установите желаемое имя файла } repositories { mavenCentral() } dependencies { implementation("io.ktor:ktor-server-core") implementation("io.ktor:ktor-server-content-negotiation") implementation("io.ktor:ktor-serialization-kotlinx-json") implementation("org.jetbrains.exposed:exposed-core:$exposed_version") implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") implementation("com.h2database:h2:$h2_version") implementation("org.postgresql:postgresql:$postgres_version") implementation("io.ktor:ktor-server-netty") implementation("ch.qos.logback:logback-classic:$logback_version") implementation("io.ktor:ktor-server-config-yaml") testImplementation("io.ktor:ktor-server-test-host") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") }
Если в Gradle вы заменили все вхождения com.ilya на свой group, делаем дальше:
Собираем Gradle — проверяем, что проект компилируется без ошибок.
Коммитим все изменения в наш репозиторий на GitHub.
Переходим на Railway.
В том же разделе, где мы создавали базу данных, или в новом (если база в другом месте), подключаем репозиторий с нашим сервером.
Выбираем репозиторий на GitHub и запускаем деплой.
После этого Railway автоматически соберёт и запустит ваш сервер.



В поле Start Command нужно указать всего одну команду для сборки и запуска сервера. Вот пример для Railway:

-Xmx — это параметр, который задаёт максимальный размер кучи JVM. Пока будем использовать 512m, но при необходимости его можно увеличить до 1024m.
Всё зависит от того, что у вас указано в Gradle — именно от команд сборки и запуска, прописанных там, будет выполняться сервер на Railway.
tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> { archiveFileName.set("server.jar") // Установите желаемое имя файла }



Теперь можно протестировать наш сервер в Postman. Проверим, что все маршруты работают корректно, и убедимся, что данные добавляются и читаются из базы.

В этой статье мы научились:
писать сервер на Kotlin с использованием Ktor,
подключать его к базе данных PostgreSQL для хранения данных,
создавать маршруты (endpoints) и работать с ними,
и всего за 5 минут задеплоить сервер с базой на Railway.
Теперь вы можете спокойно добавлять новые маршруты и обработку данных в локальной среде — всё, что собирается на вашей машине, после коммита и пуша на GitHub, автоматически будет собираться и запускаться на сервере благодаря удобному CI/CD на Railway.
Не забывайте использовать мою реферальную ссылку при первой оплате на Railway: вы получите 20$, а я — 5$.
В следующей статье мы разберём:
проверку пользователей через Firebase по UID,
создание полноценной системы JWT с Redis для кеширования и ускоренного ответа сервера,
а также дополнительные возможности для масштабирования и оптимизации.
Свои идеи и предложения тоже присылайте — буду рад их обсудить.
Надеюсь, статья была полезной, и у вас всё получилось с первого раза, как и у меня! Теперь вы знаете, как быстро создавать, тестировать и деплоить сервер на Kotlin. 🎉
