Configuración con variables de entorno en Java

Las variables de entorno son la forma habitual de configurar un programa que corre en un contenedor. Java las lee con System.getenv, y lo interesante es lo que hace falta montar alrededor.

Lo básico, y una buena noticia

System.getenv("VACIA");                    // definida, pero vacía
System.getenv("NO_EXISTE");                // no definida
System.getenv().containsKey("VACIA");
salidagetenv(VACIA)     =
  getenv(NO_EXISTE) = null
  ¿está definida?   = true

Java sí distingue una variable vacía de una que no existe: la primera devuelve la cadena vacía y la segunda null. En Go, por ejemplo, hay que usar una función aparte para notar la diferencia. Aun así conviene tratar las dos igual, porque VAR= casi siempre significa «no la he puesto».

Un ayudante por tipo

String texto(String k, String omision) {
    String v = System.getenv(k);
    return (v == null || v.isEmpty()) ? omision : v;
}

int entero(String k, int omision) {
    String v = System.getenv(k);
    if (v == null || v.isEmpty()) return omision;
    try { return Integer.parseInt(v.trim()); }
    catch (NumberFormatException e) {
        throw new IllegalStateException(k + ": \"" + v + "\" no es un entero");
    }
}

Duration duracion(String k, Duration omision) { ... Duration.parse(v.trim()) ... }

String obligatorio(String k) {
    String v = System.getenv(k);
    if (v == null || v.isEmpty()) throw new IllegalStateException(k + " es obligatoria y no está definida");
    return v;
}
salidaConfig[host=localhost, puerto=9000, depurar=true, espera=PT1.5S, clave=********]

Y cuando algo está mal, el programa no arranca a medias:

salidaerror de configuración: APP_CLAVE es obligatoria y no está definida
error de configuración: APP_PUERTO: "ocho" no es un entero
Duration.parse usa el formato ISO-8601: PT1.5S, PT30M, PT2H45M. Es menos cómodo que el 300ms de otros lenguajes, pero no hay que inventarse un analizador. Si prefieres algo más legible para quien despliega, acepta un número de segundos y conviértelo.

Que la contraseña no se escape

record Secreto(String valor) {
    @Override public String toString() {
        return valor == null || valor.isEmpty() ? "(vacío)" : "********";
    }
}

Fíjate en la salida de arriba: el Config entero se imprimió con toString y la clave salió como ********. Darle un tipo propio al secreto hace que no aparezca en ningún registro, ni en un volcado, ni en un mensaje de error, porque todo eso pasa por toString. Para usarla de verdad hay que pedir clave.valor(), que es un gesto visible en una revisión de código.

Listar y enmascarar

System.getenv().entrySet().stream()
    .filter(e -> e.getKey().startsWith("APP_"))
    .sorted(Map.Entry.comparingByKey())
    .forEach(e -> System.out.printf("  %-12s = %s%n", e.getKey(),
             e.getKey().contains("CLAVE") ? "********" : e.getValue()));
salidaAPP_CLAVE    = ********
  APP_DEPURAR  = true
  APP_ESPERA   = PT1.5S
  APP_PUERTO   = 9000

Entorno y propiedades no son lo mismo

salidajava.version = 25.0.4.1
  -Dmi.opcion  = hola
Variables de entorno Propiedades del sistema
Se leen con System.getenv(...) System.getProperty(...)
Se ponen VAR=valor java ... java -Dvar=valor ...
Alcance el proceso y sus hijos solo esa JVM
Se pueden cambiar en marcha no sí, con setProperty
Para qué configuración del despliegue ajustes de la propia JVM

En un contenedor casi siempre quieres el entorno: es lo que entienden Docker, Compose y Kubernetes sin que tengas que tocar la línea de arranque.

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.