skip to content

Your Ktor service must take its port and credentials from the environment in production. How do you wire that?

level: seniorimportance: should knowfreq 42%

answer

  1. File owns the schema, environment owns the values
  2. Optional substitution drops the line when unset
  3. Order matters: default first, override second
  4. Missing secret must stop the process

basics

~20 s

Keep one config file that defines the shape and bind sensitive or environment-specific values to HOCON substitutions such as port = ${?PORT}. EngineMain also accepts command-line overrides, so the deploy can set values without a rebuild and the code still reads a single config tree.

solid answer

~50 s

Commit one `application.conf` that declares every key the service needs, and let the *values* come from outside: HOCON's optional substitution `${?VAR}` overwrites a key when the environment variable is set and leaves the committed default in place when it is not. So `port = 8080` followed by `port = ${?PORT}` gives a working local default and a platform-driven production port. For credentials, commit the key with no literal default and let the substitution supply it — then read it with `property()` so a missing secret fails startup loudly instead of producing a service that runs unauthenticated. `EngineMain` additionally accepts command-line arguments, including `-port=` and `-P:` overrides of arbitrary config paths, which is useful for one-off runs. The rule to state: the file owns the schema, the environment owns the values, and required values are validated at module time.

code

hocon · 16 lines
hocon
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
        host = "0.0.0.0"
    }
    application { modules = [ com.example.ApplicationKt.module ] }
}

app {
    db {
        url = "jdbc:postgresql://localhost:5432/app"
        url = ${?DATABASE_URL}
        password = ${?DB_PASSWORD}
    }
}

go deeper

for a junior

Recall that a HOCON value can come from an environment variable and that the port is conventionally overridden this way in a container.

for a middle

Explain optional versus required substitution, why assignment order decides which wins, and how the config file still lists every key.

for a senior

Own the operational contract: no literal secrets, required values validated during startup so a bad deploy fails fast, and one configuration channel rather than two.

for a principal

Define the standard across services — committed schema, injected values, secret handling and the startup-validation rule — so that any service can be audited for what it reads without opening its handlers.

## The problem One artifact has to run in several environments with different ports, hostnames, database URLs and secrets, and none of those may be baked into the image. Ktor gives you three layered mechanisms; a good answer uses them in the right order rather than reaching for `System.getenv` everywhere. ## Layer 1 — the committed file defines the schema `application.conf` should list every key the service reads, even the ones with no safe default. That file is documentation with teeth: a reader sees the full configuration surface in one place, and a startup read against a declared key fails immediately if nothing supplied a value. ``` ktor { deployment { port = 8080 port = ${?PORT} } application { modules = [ com.example.ApplicationKt.module ] } } app { db { url = "jdbc:postgresql://localhost:5432/app" url = ${?DATABASE_URL} password = ${?DB_PASSWORD} } } ``` ## Layer 2 — HOCON substitution pulls values from the environment The `${?NAME}` form is an **optional** substitution: if the environment variable is set, the assignment happens; if it is unset, the line is dropped entirely and whatever value was assigned before survives. That is precisely the semantics you want for a default-plus-override pattern, and it is why the default is written on the line *above* the substitution — order matters, last assignment wins. The non-optional form `${NAME}` is different: an unresolved reference is a parse error. Use it deliberately when you want the config itself to refuse to load without the variable. Note what `password = ${?DB_PASSWORD}` does when the variable is unset: the key simply does not exist. That is a feature — pair it with `property("app.db.password")` in code so the module throws during startup. A service that boots with an absent password and then fails on its first query is strictly worse to operate. ## Layer 3 — command-line overrides for one-offs `EngineMain`'s `main` reads its arguments. Beyond the well-known `-port=` and `-host=`, it accepts `-config=` to point at an alternative file and `-P:path.to.key=value` to override any single configuration path. This is the right tool for a local run against staging or a debugging session — not for the standing deployment, which should be reproducible from the environment alone. ## What to say about secrets Three points separate a senior answer from a middling one. **Never a literal in the file.** The committed config carries the *key*, not the value. Anything with a literal secret is a leak the moment the repo is cloned, and rotating it becomes a code change. **Validate at startup, not at use.** Map configuration into typed settings inside the module function using the throwing accessor. Deploy pipelines watch for a process that fails to come up; they do not watch for a handler that starts 500ing under load. **Do not log the values.** Log that configuration loaded and which environment was selected, never the resolved secrets. If a startup summary is useful, log key names and whether each was supplied. ## The anti-pattern to name Sprinkling `System.getenv("DATABASE_URL")` through the code bypasses the config tree. Now the service has two configuration channels, only one of which is discoverable from the file; tests cannot override the second without mutating process environment; and nobody can enumerate what the service actually reads. Routing everything through `environment.config` keeps a single, inspectable surface — the environment becomes a *source* feeding it rather than a competing mechanism. ## A workable shape ``` data class AppSettings(val dbUrl: String, val dbPassword: String) fun Application.settings(): AppSettings { val db = environment.config.config("app.db") return AppSettings( dbUrl = db.property("url").getString(), dbPassword = db.property("password").getString(), ) } ``` Call it once at the top of the module. Every downstream component takes `AppSettings`, which makes them trivially testable and makes the configuration contract a compile-time type rather than a set of strings scattered across handlers. ## Container detail worth knowing Many platforms inject the port as `PORT` and expect the process to bind it; that is exactly what `port = ${?PORT}` covers. Also bind the host to `0.0.0.0` rather than `localhost` in a container, since a loopback bind is unreachable from outside the container regardless of correct port configuration.

  • What is the difference between ${NAME} and ${?NAME} in a Ktor application.conf?
    The plain form is required: if the variable is unresolved, the configuration fails to parse. The question-mark form is optional: an unset variable makes the whole assignment disappear, leaving any earlier value for that key intact. That difference is what makes default-then-override ordering work.
  • Why validate required configuration in the module function rather than where it is used?
    Because startup failure is the signal every deploy pipeline already watches. Validating at first use converts a configuration mistake into a partial outage discovered by a user, and it can leave the process reporting healthy while a subset of routes is broken.
  • How would you let a developer point a local run at a staging config without editing files?
    Pass EngineMain a different file with -config=, or override individual paths with -P:app.db.url=... on the command line. Both are per-invocation, which is the right scope for a debugging session; standing environments should still be reproducible from environment variables alone.

saying these in an interview costs you the question

  • Commits a real secret as a default value in application.conf
  • Uses System.getenv scattered through handlers instead of the config tree
  • Defaults a missing password to an empty string and continues
  • Writes the substitution above the default so the override is overwritten
  • Logs the resolved configuration including credentials at startup

context