Every Kotlin service I start needs the same day of work before it does anything: two targets that both run, a store that works on both, a shutdown that finishes the request it is holding, three health endpoints that answer three different questions, an image, and enough CI to tell whether any of it is true. None of that is the product. All of it is the part you get wrong quietly.
keel is that day, done once. It is a GitHub template: clone it, rename it, and you have a service with one route, one table and one entity — and two artefacts that ship.
git clone https://github.com/youndie/keel my-service && cd my-service
./gradlew build
Renaming is genuinely all that is left. The first service built from it — a webhook relay — came to 249 lines of renaming across 32 files, every one a substitution, against 178 lines of its own domain. No file under Dockerfile, Makefile, settings.gradle.kts, gradle.properties, .github/ or scripts/ changed at all.
The numbers
Measured on 2026-09-16, each on hardware named beside it, because a template that publishes an unmeasured number is the first thing its reader checks against their own clock.
git clone → both halves answering /health/ready |
3 min 48 s |
| of which the build, almost all Kotlin/Native toolchain | 82 % |
image, gcr.io/distroless/cc-debian13 |
13 972 497 bytes |
the linuxX64 binary inside it |
9 228 056 bytes, declaring 7 shared libraries |
| resident at rest | 14 136 kB |
| p95 at a delivered 499.95 req/s | 1.86 ms |
| tests, from one source | 23 on each of jvm and linuxX64, the same binary run on arm64 |
The first row is the one worth the qualification: it was taken in a fresh container with no Gradle cache, no Kotlin/Native toolchain and no Docker layers, cloning over the network — a machine that had never seen any of this. The image build is not in that figure, because the timing container had no Docker; separately it is about two minutes.
The latency row was taken on two hosts, subject and generator on separate machines over a private link, four runs of thirty seconds with the first discarded. On one machine the generator competes with the subject for CPU and the number describes the laptop rather than the service, which is why the tool refuses to write a result unless you name two hosts.
What "ships twice" actually means
Not "compiles for two targets". Both halves run, and the interesting one is what happens when you stop them.
Send SIGTERM under load and every request already in flight finishes: 32 of 32, on each half, asserted from the client's record rather than from the server's log. Requests arriving after the announce get 503 with Connection: close. The process ends itself — 0 on the native binary, 143 on the JVM, both correct — inside the grace period.
The two also answer identically. The same k6 scenario against both produces no diff after a normaliser that declares, before the first run, exactly what the platforms may disagree about: the exit code, the Server and Date headers, and /version's build timestamp. Anything it does not name is a difference that fails.
Before you clone
One thing is not true yet and it would be unfair to bury it. The libraries keel builds on are not on Maven Central, so a clone needs the repository they live in:
// settings.gradle.kts, inside pluginManagement { repositories { … } }
maven("https://reposilite.kotlin.website/snapshots") {
content { includeGroupByRegex("io\\.github\\.youndie.*") }
}
That block is in the template already. Until those artefacts reach Central, "clone and run" means "clone and run, with that repository configured" — which is most of the way there for me and not all the way there for you.
What it deliberately does not have
No authentication: a template that ships half of one ships it to every clone. No Helm chart: that is a decision about a cluster the template does not have. No client. No second entity — the example is Item { id, name } and it stays that way, because a starter whose example grows features stops being a starter.
And no static scratch image, which the first draft of the plan called for. It would have saved about 4.5 MB on an image already 44 % under its budget, at the price of a recipe pinning five compiler properties that a Kotlin release can change. A convention that breaks on a version bump gets fixed once for everybody; a template that breaks on one is copied and never updated again. The recipe is written down in the repository for anyone who wants it deliberately.
Where the interesting parts live
keel is thin on purpose — the lifecycle is kore and the build conventions are sborka, and this page is not about either. What keel does is show them wired, with a number beside every claim and a note beside every absence.
If you want the reasoning rather than the result, the repository carries it: a research document whose every fact names where it was verified, and a backlog where each closed item says what it found that nobody planned for. Several of those findings were defects in the libraries above, discovered by being their second consumer rather than their first.