Aplikasi modern berjalan di banyak environment: development lokal, staging, production, dan kadang multiple region. Konfigurasi yang berbeda per environment tidak boleh hardcode di kode — itu melanggar prinsip 12-factor app. Solusinya adalah environment variables: nilai yang dioper ke proses oleh OS atau container runtime. Environment variables cocok untuk secrets (password, API key) karena tidak masuk ke repository dan dapat diganti tanpa rebuild. Pada chapter ini kita akan mendemokan pembacaan env vars dengan System.getenv(), normalisasi nama dari SNAKE_CASE ke dot.notation, validasi wajib fail-fast, serta praktik terbaik untuk Docker dan Kubernetes.

Membaca Environment Variables #

System.getenv("KEY") mengembalikan String? — null bila tidak ada. Env vars selalu String, sehingga konversi tipe (Int, Boolean) harus dilakukan manual. Untuk aplikasi Kotlin, bungkus akses dengan EnvReader yang menangani default, parsing, dan masking secret:

kotlin
// environment_variables.kt — Demo env vars: baca, normalisasi, dan validasi

import java.util.Properties
import java.io.StringReader

// === Env var normalizer: APP_DB_URL -> app.db.url ===
fun normalizeEnvKey(envKey: String): String {
    return envKey.lowercase().replace('_', '.')
}

// === Typed env reader ===
class EnvReader(private val env: Map<String, String>) {
    fun getString(key: String, default: String? = null): String {
        return env[key] ?: default ?: error("Env var $key tidak ditemukan")
    }
    fun getStringOrNull(key: String): String? = env[key]
    fun getInt(key: String, default: Int? = null): Int {
        val raw: String = env[key] ?: default?.toString() ?: error("Env var $key tidak ditemukan")
        return raw.toIntOrNull() ?: error("Env var $key bukan integer: $raw")
    }
    fun getBoolean(key: String, default: Boolean = false): Boolean {
        val raw = env[key] ?: return default
        return raw.lowercase() in listOf("true", "1", "yes", "on")
    }
    fun requireAll(keys: List<String>): Map<String, String> {
        val missing = keys.filter { it !in env }
        if (missing.isNotEmpty()) {
            error("Env vars wajib tidak ditemukan: ${missing.joinToString()}")
        }
        return keys.associateWith { env[it]!! }
    }
}

// === Tipe data konfigurasi dari env ===
data class EnvConfig(
    val port: Int,
    val dbUrl: String,
    val dbUser: String,
    val dbPassword: String,
    val jwtSecret: String,
    val featureX: Boolean,
    val logLevel: String
)

fun main() {
    println("=== Environment Variables ===")
    println()
    println("Sumber konfigurasi 12-factor app; secrets di env vars.")
    println()

    // === 1. Baca env vars (simulasi untuk demo deterministik) ===
    println("=== 1. Environment Variables (simulasi) ===")
    val mockEnv = mapOf(
        "APP_PORT" to "8080",
        "APP_DB_URL" to "jdbc:postgresql://db.example.com/app",
        "APP_DB_USER" to "app_user",
        "APP_DB_PASSWORD" to "YOUR_DB_PASSWORD",
        "APP_JWT_SECRET" to "YOUR_JWT_SECRET",
        "APP_FEATURE_X" to "true",
        "APP_LOG_LEVEL" to "INFO"
    )
    mockEnv.forEach { (k, v) ->
        val display = if (k.contains("PASSWORD") || k.contains("SECRET")) "********" else v
        println("  " + k.padEnd(22) + " = " + display)
    }
    println()

    // === 2. Normalisasi kunci ===
    println("=== 2. Normalisasi APP_DB_URL -> app.db.url ===")
    mockEnv.keys.forEach { envKey ->
        val normalized = normalizeEnvKey(envKey)
        println("  " + envKey.padEnd(22) + " -> " + normalized)
    }
    println()

    // === 3. Baca dengan EnvReader ===
    println("=== 3. EnvReader (typed) ===")
    val reader = EnvReader(mockEnv)
    println("  APP_PORT: ${reader.getInt("APP_PORT")}")
    println("  APP_DB_URL: ${reader.getString("APP_DB_URL")}")
    println("  APP_FEATURE_X: ${reader.getBoolean("APP_FEATURE_X")}")
    println("  APP_TIMEOUT (default 3000): ${reader.getInt("APP_TIMEOUT", 3000)}")
    println()

    // === 4. Validasi wajib ===
    println("=== 4. Validasi Env Vars Wajib ===")
    val required = listOf("APP_DB_URL", "APP_DB_USER", "APP_DB_PASSWORD", "APP_JWT_SECRET")
    val wajib = reader.requireAll(required)
    println("  Semua wajib hadir: ${wajib.size} vars")
    println()

    // === 5. Bangun EnvConfig ===
    println("=== 5. EnvConfig (typed) ===")
    val config = EnvConfig(
        port = reader.getInt("APP_PORT"),
        dbUrl = reader.getString("APP_DB_URL"),
        dbUser = reader.getString("APP_DB_USER"),
        dbPassword = reader.getString("APP_DB_PASSWORD"),
        jwtSecret = reader.getString("APP_JWT_SECRET"),
        featureX = reader.getBoolean("APP_FEATURE_X"),
        logLevel = reader.getString("APP_LOG_LEVEL", "INFO")
    )
    println("  port = ${config.port}")
    println("  dbUrl = ${config.dbUrl}")
    println("  dbUser = ${config.dbUser}")
    println("  dbPassword = ******** (disembunyikan)")
    println("  jwtSecret = ******** (disembunyikan)")
    println("  featureX = ${config.featureX}")
    println("  logLevel = ${config.logLevel}")
    println()

    // === 6. Anti-pattern ===
    println("=== 6. Anti-pattern (hindari) ===")
    val antiPatterns = listOf(
        "1. Hardcode password di kode -> gunakan env var",
        "2. Commit .env ke git -> tambahkan ke .gitignore",
        "3. Log env var sensitif -> mask sebelum log",
        "4. Baca env var di function -> cache di startup",
        "5. Pola dot.notation di env -> SNAKE_CASE; normalisasi saat load",
        "6. Tidak ada validasi -> fail-fast pada startup"
    )
    antiPatterns.forEach { println("  " + it) }
    println()

    // === 7. Aturan praktis ===
    println("=== 7. Aturan Praktis ===")
    val rules = listOf(
        "1. 12-factor app: konfigurasi di env, bukan di kode",
        "2. Prefix konsisten (APP_*) untuk hindari tabrakan",
        "3. Secrets (password, API key) WAJIB via env var",
        "4. .env file untuk lokal dev; JANGAN commit ke repo",
        "5. Docker -e / --env-file untuk inject ke container",
        "6. Kubernetes ConfigMap + Secret untuk produksi",
        "7. Rotasi secret periodik; baca ulang tanpa restart bila perlu"
    )
    rules.forEach { println("  " + it) }
}

Output yang Dihasilkan:

text
=== Environment Variables ===

Sumber konfigurasi 12-factor app; secrets di env vars.

=== 1. Environment Variables (simulasi) ===
  APP_PORT               = 8080
  APP_DB_URL             = jdbc:postgresql://db.example.com/app
  APP_DB_USER            = app_user
  APP_DB_PASSWORD        = ********
  APP_JWT_SECRET         = ********
  APP_FEATURE_X          = true
  APP_LOG_LEVEL          = INFO

=== 2. Normalisasi APP_DB_URL -> app.db.url ===
  APP_PORT               -> app.port
  APP_DB_URL             -> app.db.url
  APP_DB_USER            -> app.db.user
  APP_DB_PASSWORD        -> app.db.password
  APP_JWT_SECRET         -> app.jwt.secret
  APP_FEATURE_X          -> app.feature.x
  APP_LOG_LEVEL          -> app.log.level

=== 3. EnvReader (typed) ===
  APP_PORT: 8080
  APP_DB_URL: jdbc:postgresql://db.example.com/app
  APP_FEATURE_X: true
  APP_TIMEOUT (default 3000): 3000

=== 4. Validasi Env Vars Wajib ===
  Semua wajib hadir: 4 vars

=== 5. EnvConfig (typed) ===
  port = 8080
  dbUrl = jdbc:postgresql://db.example.com/app
  dbUser = app_user
  dbPassword = ******** (disembunyikan)
  jwtSecret = ******** (disembunyikan)
  featureX = true
  logLevel = INFO

=== 6. Anti-pattern (hindari) ===
  1. Hardcode password di kode -> gunakan env var
  2. Commit .env ke git -> tambahkan ke .gitignore
  3. Log env var sensitif -> mask sebelum log
  4. Baca env var di function -> cache di startup
  5. Pola dot.notation di env -> SNAKE_CASE; normalisasi saat load
  6. Tidak ada validasi -> fail-fast pada startup

=== 7. Aturan Praktis ===
  1. 12-factor app: konfigurasi di env, bukan di kode
  2. Prefix konsisten (APP_*) untuk hindari tabrakan
  3. Secrets (password, API key) WAJIB via env var
  4. .env file untuk lokal dev; JANGAN commit ke repo
  5. Docker -e / --env-file untuk inject ke container
  6. Kubernetes ConfigMap + Secret untuk produksi
  7. Rotasi secret periodik; baca ulang tanpa restart bila perlu

Mari kita bedah demo di atas:

  1. Env Vars Simulasi: Demo memakai mockEnv map agar output deterministik. Di production, ganti dengan System.getenv() yang mengembalikan Map<String, String> dari OS.
  2. Normalisasi: APP_DB_URL ditransformasi menjadi app.db.url. Ini memudahkan integrasi dengan file config berformat HOCON/YAML yang memakai dot notation. Function normalizeEnvKey sederhana: lowercase + ganti _ dengan ..
  3. EnvReader typed: membungkus akses env[key] dengan parsing tipe. getInt("APP_PORT") mengembalikan Int, getBoolean menerima "true", "1", "yes", "on" sebagai nilai true. Default di-support untuk semua tipe.
  4. Validasi Wajib: requireAll(listOf(...)) memeriksa bahwa semua env var wajib hadir sebelum startup. Bila ada yang hilang, error(...) melempar exception dengan pesan jelas — fail-fast lebih baik daripada runtime error tidak jelasus setelah 10 menit.
  5. EnvConfig typed: hasil akhir dipetakan ke data class EnvConfig. Secret (dbPassword, jwtSecret) disimpan tetapi saat dicetak, demo memask dengan "********". Implementasi nyata sebaiknya tidak mengimplementasikan toString() default — gunakan toString() custom yang memask secret.

System.getenv() vs System.getProperty() #

AspekSystem.getenv()System.getProperty()
SumberOS environmentJVM args (-D)
Setelah startupTidak bisa diubah (immutable)Bisa diubah via System.setProperty()
KonvensiSCREAMING_SNAKE_CASEdot.notation
Cocok untukSecrets, 12-factor configOverride development
AksesSystem.getenv("KEY")System.getProperty("key")

Env vars lebih sesuai untuk deployment karena tidak bisa diubah setelah startup — sifat penting untuk audit dan reproducibility. System properties berguna untuk debugging ad-hoc (-Ddebug=true).

Docker dan Kubernetes #

Docker mengoper env vars via flag -e (single) atau --env-file (bulk):

bash Docker env vars
docker run -e APP_PORT=8080 \
           -e APP_DB_PASSWORD=$APP_DB_PASSWORD \
           -p 8080:8080 \
           my-app:latest

# atau via file (tidak commit .env ke repo)
docker run --env-file .env.production my-app:latest

Kubernetes memisahkan konfigurasi non-sensitif (ConfigMap) dan sensitif (Secret):

yaml k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  template:
    spec:
      containers:
        - name: app
          image: my-app:latest
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secret

Anti-pattern yang Harus Dihindari #

  1. Hardcode password di kode: val password = "s3cr3t" — siapa pun yang baca repository tahu password.
  2. Commit .env ke git: tambahkan .env* ke .gitignore. Gunakan .env.example sebagai template.
  3. Log env var sensitif: println("Connecting with $password") bocor ke log aggregator. Selalu mask.
  4. Baca env var tiap function call: baca sekali di startup, simpan di object/factory.
  5. Tidak validasi: aplikasi startup OK tetapi crash 10 menit kemudian karena APP_PORT tidak ada. Fail-fast lebih baik.

Referensi Lanjutan #

  • Spring Boot — dokumentasi Spring Boot untuk konsumsi environment variable dan relaxed binding

Selanjutnya #

Setelah konfigurasi terkelola, langkah berikutnya adalah observability — logging terstruktur, trace ID, dan korelasi antar service. Materi tersebut dibahas pada chapter Logging dan Observability.