Skip to content
Kona

Config & env

Applications need settings that change between machines: the app’s name, whether debug output is shown, which port to listen on, database credentials. Kona separates these into two layers:

  • Environment (org.konaframework.core.Env): flat string variables from the operating system, with an optional .env file for development. This is where machine-specific and secret values belong.
  • Config (org.konaframework.core.Config): a structured tree of typed values, addressed with dotted keys such as app.debug. It is filled from environment-derived defaults, executable config modules under config/, and optional configure callbacks.

Your code should mostly read config. Environment variables are the raw input that seeds it.

When you boot with Kona.create(...), Kona fills config in this order. A later step overwrites keys set by an earlier one.

  1. .env is loaded from the base path, if the file exists. Missing files are skipped silently.

  2. Defaults are seeded from the environment:

    Config key Environment variable Default when unset
    app.name APP_NAME "Kona"
    app.env APP_ENV "local"
    app.debug APP_DEBUG (parsed as a boolean) false
    app.url APP_URL "http://127.0.0.1:4000"
    app.key APP_KEY unset unless present
  3. Registered ConfigFile modules merge in registration order. Each module returns a map that lands under its key (app, database, session, …). Modules may call Env.get / Env.getOrDefault while building that map. Path overrides from a paths module are applied when the layout refreshes after this step.

  4. Your configure callbacks run, in the order you added them.

So a config.App module that sets "name" from APP_NAME can refine the seeded default, and a configure { it.set("app.name", ...) } overrides both. Applications built with Application.builder (without Kona.create) skip steps 1 to 2 unless you register the same bootstrappers yourself; they still merge any .configs(...) you add.

Config is filled once, at startup. Editing .env or a config module’s source while the app runs has no effect until restart (and recompile for modules).

Put Kotlin or Java classes in the language root config/ package (next to app/, bootstrap/, routes/):

src/main/kotlin/config/
App.kt → key "app"
Database.kt → key "database"
Session.kt → key "session"
Filesystems.kt → key "filesystems"
Http.kt → key "http"
Paths.kt → key "paths"

Each module implements org.konaframework.core.ConfigFile with two methods:

  • key() — top-level config key (usually the file stem in lowercase: App → "app").
  • config() — nested map of values for that key. Call Env here for machine-specific values.

Register them explicitly from bootstrap.App (reliable and easy to test):

Kona.create(args)
.configs(config.App, config.Database, config.Session, config.Filesystems, config.Http, config.Paths)
.providers(/* ... */)
.boot()
config/Database.kt
package config
import org.konaframework.core.ConfigFile
import org.konaframework.core.Env
object Database : ConfigFile {
override fun key(): String = "database"
override fun config(): Map<String, Any?> = mapOf(
"default" to Env.getOrDefault("DB_CONNECTION", "main"),
"connections" to mapOf(
"main" to mapOf(
"url" to Env.getOrDefault("DB_URL", "jdbc:h2:mem:kona;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"),
"username" to Env.getOrDefault("DB_USERNAME", "sa"),
"password" to Env.getOrDefault("DB_PASSWORD", ""),
),
),
)
}

You can also build a module inline with ConfigFile.of("cache", () -> Map.of("driver", "memory")).

kona new scaffolds these modules and wires .configs(...) in bootstrap.App for both Kotlin and Java (and every starter kit overlay).

There is no JSON or .properties config directory loading. Settings live in source next to the rest of the app so they can call Env and stay type-checked.

PathLayout still honors paths.langRoot, paths.resources, paths.storage, and paths.public. Ship them from config/Paths (or omit the module and keep framework defaults). Application.configPath() / Paths.config() resolve to the language-root config/ directory (for example src/main/kotlin/config), not src/main/resources/config.

Env.get checks the real process environment first and falls back to values loaded from .env. This precedence is deliberate: a deployment platform, CI job, or container can inject production values and secrets without the file being edited or even present.

val host = Env.getOrDefault("HOST", "127.0.0.1")
val region = Env.get("REGION") // String? — null when unset
val apiKey = Env.require("PAYMENTS_API_KEY") // throws when unset
Method Behavior
get(key, default) Process env, then .env, then default (Kotlin may omit default; Java passes null)
getOrDefault(key, default) Same, with a non-null result
require(key) Same, but throws IllegalStateException("Missing environment variable [KEY]") when absent
load(path) Parse a .env file and remember its values (later loads overwrite earlier keys)
set(key, value) Set a fallback value in code; real process variables still win
clear() Forget loaded and set values; also done by Kona.reset()

Env is process-wide, not per application. Tests that boot several applications should call Kona.reset() between them.

Terminal window
# Comments start with #
APP_NAME=billing
APP_DEBUG=on
export PORT=9000 # an "export " prefix is allowed
GREETING="Hello # not a comment"
SINGLE='single quotes work too'
EMPTY=
  • One KEY=value per line. Whitespace around the key and value is trimmed.
  • Blank lines and lines starting with # are skipped, as are lines without = or with nothing before it.
  • In an unquoted value, # (space then hash) starts a trailing comment. Inside single or double quotes, # is literal.
  • Surrounding quotes are removed. There is no variable interpolation, escape processing, or multi-line value support.

APP_DEBUG, and anything you read with getBoolean, accepts these strings in any letter case:

True False
true, 1, yes, on false, 0, no, off, empty string

Numbers count as true when non-zero. For APP_DEBUG, any other string (for example maybe) is treated as false. With app.debug on, the HTTP layer adds exception messages and class names to error responses; keep it off on public servers.

Config stores nested maps. A dotted key walks into the tree: set("app.debug", true) creates { "app": { "debug": true } }, and get("app") returns the whole app map.

val config = app.config
config.set("mail.host", "smtp.example.com")
config.set("mail.port", 587)
config.set("mail", mapOf("from" to "noreply@example.com")) // replaces the mail subtree
val name = config.getString("app.name", "Kona")
val port = config.getInt("server.port", 8080)
val debug = config.getBoolean("app.debug", false)
val hasMail = config.has("mail.from") // true
val mail: Map<String, Any?>? = config.get("mail")
Method Returns
set(key, value) Stores value, creating intermediate maps. Map values are copied into the tree. Setting below a non-map value replaces it with a map.
has(key) true if the full path exists, even when the stored value is null
get(key) / get(key, default) The raw value (or nested map), cast to the type you assign to; default only when the key is missing
getString(key) / getString(key, default) toString() of the value
getInt(key) / getInt(key, default) Numbers truncated to Int; numeric strings parsed; anything else counts as missing
getBoolean(key) / getBoolean(key, default) Using the boolean rules above; unrecognised strings count as missing
all() A deep copy of the whole tree, safe to modify
loadMap(prefix, map) Deep-merge a map under prefix (empty prefix merges at the root)
load(ConfigFile) Merge one executable module under its key()

The single-argument typed getters return null (Kotlin Int?, Java Integer) when the key is missing or not convertible. The two-argument forms return the default instead, which is what you usually want. get performs an unchecked cast: assigning a string value to an Integer compiles but fails with ClassCastException when used, so prefer the typed getters for scalars.

Reads and writes are synchronized, so config is safe to read from request threads. Treat it as read-only after boot anyway; it is meant for settings, not shared mutable state.

You can still set keys in a configure callback without a dedicated module:

Kona.create(args)
.configs(config.App, config.Database)
.configure { config ->
config.loadMap("feature", mapOf("beta" to true))
}
.boot()
  • Missing required environment variable read with Env.require: IllegalStateException naming the variable.
  • Exceptions thrown inside ConfigFile.config() during boot propagate and fail fast before the app serves requests.

See also Application & providers for where configuration fits in the boot sequence.