On This Page
The config you'll end up with
A parcel-tracking command-line tool needs an API address, a retry count, a timeout and an API key. On a laptop you want defaults. In a container you want environment variables. When you're debugging you want to override one value without touching anything else. This is what the finished program prints in three situations: nothing set, then a mix of -D flags and environment variables, then a bad number.
$ java -cp out dev.ggorantala.parcel.Main
parcel.api.url = (using default) [default]
parcel.retries = (using default) [default]
parcel.timeout = (using default) [default]
parcel.verbose = (using default) [default]
parcel.api.key = (using default) [default]
-> would call http://localhost:8080 up to 4 times, 10s each, verbose=false
api key present: false
$ PARCEL_API_URL=http://env.example PARCEL_RETRIES=5 PARCEL_API_KEY=abc123 \
java -Dparcel.api.url=http://prop.example -Dparcel.timeout=30 -Dparcel.verbose=true -cp out dev.ggorantala.parcel.Main
parcel.api.url = http://prop.example [system property]
parcel.retries = 5 [environment variable PARCEL_RETRIES]
parcel.timeout = 30 [system property]
parcel.verbose = true [system property]
parcel.api.key = **** [environment variable PARCEL_API_KEY]
-> would call http://prop.example up to 6 times, 30s each, verbose=true
api key present: true
$ PARCEL_RETRIES=three java -cp out dev.ggorantala.parcel.Main
Exception in thread "main" java.lang.IllegalStateException: Setting 'parcel.retries' must be a whole number but was 'three'
at dev.ggorantala.parcel.Settings.parse(Settings.java:68)
If you searched for how to read environment variables in Java, the one-liner is System.getenv("NAME"), and for a -D flag it's System.getProperty("name"). Both return null when the value isn't there, and that null is where most config bugs start. So this article builds a small Settings class on top of them with one rule (property first, then environment variable, then default), typed getters that fail loudly, and a startup line that tells you where each value came from without printing the secret.
It's for you if you can compile and run a Java class from a terminal. There are three classes, a demo and a checks file. No framework, no build tool, no container.
I built it on plain javac because Maven Central wasn't reachable from my build machine. Writing Settings was the quick part. Most of the time went on the traps near the end, because several of them print something that looks right and isn't.
What you need to follow along
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| OS | Ubuntu 24.04 (Linux) | uname -a |
| Shell | bash | echo $SHELL |
| Build tool | none | |
| Container runtime | none needed |
The Settings class compiles with --release 17 too (I checked), so Java 17 works. If you need a JDK, SDKMAN installs one with sdk install java 21.0.12-tem; check sdk list java for the exact identifier on your machine, I didn't run SDKMAN myself.
The shell examples use NAME=value command, which works in bash and zsh. In PowerShell it's $env:NAME="value"; java ..., and in cmd it's set NAME=value on its own line. I didn't run either on Windows.
Create the parcel-tracker project
mkdir -p parcel/src/main/java/dev/ggorantala/parcel
mkdir -p parcel/src/test/java/dev/ggorantala/parcel
cd parcel
parcel/
├── src/main/java/dev/ggorantala/parcel/
│ ├── Basics.java
│ ├── Settings.java
│ ├── Main.java
│ └── Traps.java
└── src/test/java/dev/ggorantala/parcel/
└── Checks.java
In IntelliJ IDEA: File | New | Project, choose Java, pick JDK 21, then create the package dev.ggorantala.parcel. To set an environment variable or a -D flag for a run, open Run | Edit Configurations, pick your Main class, and use the Environment variables field and the VM options field (-Dparcel.timeout=30). If you don't see VM options, click Modify options and tick it. I did not run IntelliJ for this article; the names come from the JetBrains help pages.
In Eclipse: File | New | Java Project with JavaSE-21, then File | New | Package. For a launch: Run | Run Configurations, select your Java Application, then the Arguments tab for VM arguments and the Environment tab for variables. Same caveat: docs only, not executed.
From the command line (what I ran):
mkdir -p out
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
On Windows PowerShell, $(find ...) doesn't exist. Use javac --release 21 -d out (Get-ChildItem -Recurse -Filter *.java src | ForEach-Object FullName). Not executed by me.
How to read environment variables and system properties in Java, step by step
Step 1: Call the raw JDK methods and look at what comes back
Goal: see the five calls you'll build on, with their null behaviour on screen.
src/main/java/dev/ggorantala/parcel/Basics.java
package dev.ggorantala.parcel;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
/** The raw JDK calls, one per line, so you can see what each returns. */
public class Basics {
public static void main(String[] args) {
// Environment variables
System.out.println("HOME = " + System.getenv("HOME"));
System.out.println("NO_SUCH_VAR = " + System.getenv("NO_SUCH_VAR"));
Map<String, String> env = System.getenv();
System.out.println("env is a " + env.getClass().getName());
try {
env.put("X", "1");
} catch (UnsupportedOperationException e) {
System.out.println("env.put -> UnsupportedOperationException (read-only)");
}
// System properties
System.out.println("java.version = " + System.getProperty("java.version"));
System.out.println("os.name = " + System.getProperty("os.name"));
System.out.println("user.dir = " + System.getProperty("user.dir"));
System.out.println("no.such.prop = " + System.getProperty("no.such.prop"));
System.out.println("with default = " + System.getProperty("no.such.prop", "fallback"));
System.out.println("file.separator = '" + System.getProperty("file.separator") + "'");
System.out.println("line.separator escaped = "
+ System.lineSeparator().replace("\n", "\\n").replace("\r", "\\r"));
// Typed readers on System
System.out.println("Integer.getInteger = " + Integer.getInteger("parcel.retries", 3));
System.out.println("Boolean.getBoolean = " + Boolean.getBoolean("parcel.verbose"));
System.out.println("Long.getLong = " + Long.getLong("parcel.big", 5L));
// Setting a property at runtime
System.setProperty("parcel.runtime", "yes");
System.out.println("after setProperty = " + System.getProperty("parcel.runtime"));
System.clearProperty("parcel.runtime");
System.out.println("after clearProperty = " + System.getProperty("parcel.runtime"));
// Listing: only parcel.* properties
Properties all = System.getProperties();
Map<String, String> mine = new TreeMap<>();
for (String name : all.stringPropertyNames()) {
if (name.startsWith("parcel.")) mine.put(name, all.getProperty(name));
}
System.out.println("parcel.* properties = " + mine);
}
}
Run it with three -D flags so there's something to read:
java -Dparcel.retries=7 -Dparcel.verbose=true -Dparcel.zone=eu -cp out dev.ggorantala.parcel.Basics
HOME = /root
NO_SUCH_VAR = null
env is a java.util.Collections$UnmodifiableMap
env.put -> UnsupportedOperationException (read-only)
java.version = 21.0.12.1
os.name = Linux
user.dir = /tmp/w/parcel
no.such.prop = null
with default = fallback
file.separator = '/'
line.separator escaped = \n
Integer.getInteger = 7
Boolean.getBoolean = true
Long.getLong = 5
after setProperty = yes
after clearProperty = null
parcel.* properties = {parcel.retries=7, parcel.verbose=true, parcel.zone=eu}
Checkpoint: NO_SUCH_VAR = null, the environment map is read-only, and the last line lists exactly your three flags. If HOME prints null you're on Windows, where it's USERPROFILE.
Three things to notice. System.getenv() hands you an unmodifiable map, so a Java program can read its environment but never change it. System.getProperty has a two-argument form that supplies a default, System.getenv does not. And Integer.getInteger("parcel.retries", 3) read the property named parcel.retries; it did not parse the string "parcel.retries". The name is confusing and trap 1 below shows why it bites.
Step 2: Write one Settings class with a single precedence rule
Goal: one place that answers "what is this setting?" the same way everywhere.
My rule is: a -D property wins, then an environment variable, then the default. The -D flag is typed by a person on purpose for this run. The environment variable is whatever the container or shell happened to carry. I'd rather the deliberate thing win.
Environment variable names are conventionally upper case with underscores, property names are lower case with dots, so Settings converts: parcel.api.url becomes PARCEL_API_URL.
The constructor takes two lookup functions instead of calling System.getenv directly. That's the whole testing story: in a check I pass maps, in production I pass System::getenv.
src/main/java/dev/ggorantala/parcel/Settings.java
package dev.ggorantala.parcel;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
/**
* Reads settings with one precedence rule: -D system property, then environment
* variable, then the default. Both lookups are injected so tests don't touch
* the real process environment.
*/
public final class Settings {
private final Function<String, String> properties;
private final Function<String, String> environment;
public Settings(Function<String, String> properties, Function<String, String> environment) {
this.properties = Objects.requireNonNull(properties);
this.environment = Objects.requireNonNull(environment);
}
/** The real thing: System.getProperty and System.getenv. */
public static Settings fromJvm() {
return new Settings(System::getProperty, System::getenv);
}
/** Test helper: fixed maps instead of the real JVM state. */
public static Settings of(Map<String, String> props, Map<String, String> env) {
return new Settings(props::get, env::get);
}
/** parcel.api.url becomes PARCEL_API_URL. */
static String envName(String key) {
return key.toUpperCase().replace('.', '_').replace('-', '_');
}
public Optional<String> find(String key) {
String fromProp = properties.apply(key);
if (fromProp != null && !fromProp.isBlank()) {
return Optional.of(fromProp.strip());
}
String fromEnv = environment.apply(envName(key));
if (fromEnv != null && !fromEnv.isBlank()) {
return Optional.of(fromEnv.strip());
}
return Optional.empty();
}
public String string(String key, String fallback) {
return find(key).orElse(fallback);
}
public String required(String key) {
return find(key).orElseThrow(() -> new IllegalStateException(
"Missing setting '" + key + "': pass -D" + key + "=... or set " + envName(key)));
}
public int integer(String key, int fallback) {
return find(key).map(v -> parse(key, v)).orElse(fallback);
}
private static int parse(String key, String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
throw new IllegalStateException("Setting '" + key + "' must be a whole number but was '" + value + "'", e);
}
}
public boolean bool(String key, boolean fallback) {
return find(key).map(v -> {
if (v.equalsIgnoreCase("true")) return true;
if (v.equalsIgnoreCase("false")) return false;
throw new IllegalStateException("Setting '" + key + "' must be true or false but was '" + v + "'");
}).orElse(fallback);
}
public Duration seconds(String key, Duration fallback) {
return find(key).map(v -> Duration.ofSeconds(parse(key, v))).orElse(fallback);
}
/** Which source won, for the startup log. Never prints the value of a secret. */
public String describe(String key, boolean secret) {
String fromProp = properties.apply(key);
String fromEnv = environment.apply(envName(key));
String source = (fromProp != null && !fromProp.isBlank()) ? "system property"
: (fromEnv != null && !fromEnv.isBlank()) ? "environment variable " + envName(key)
: "default";
String shown = secret ? (find(key).isPresent() ? "****" : "(using default)") : find(key).orElse("(using default)");
return key + " = " + shown + " [" + source + "]";
}
}
Why these choices: find treats a blank value as missing, because PARCEL_API_URL= (set but empty) is almost always a deployment mistake and a blank URL is never what you want. It also strips whitespace, because I've seen a trailing newline sneak in from a secret file. The typed getters throw IllegalStateException with the setting name and the bad value, instead of quietly falling back to the default. required says how to fix it in its message.
Checkpoint: compile.
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
No output means success.
Step 3: Print where every value came from, and hide the secret
Goal: a startup log that makes "why is it calling the wrong URL?" a ten-second question.
src/main/java/dev/ggorantala/parcel/Main.java
package dev.ggorantala.parcel;
import java.time.Duration;
public class Main {
public static void main(String[] args) {
Settings s = Settings.fromJvm();
String url = s.string("parcel.api.url", "http://localhost:8080");
int retries = s.integer("parcel.retries", 3);
Duration timeout = s.seconds("parcel.timeout", Duration.ofSeconds(10));
boolean verbose = s.bool("parcel.verbose", false);
System.out.println(s.describe("parcel.api.url", false));
System.out.println(s.describe("parcel.retries", false));
System.out.println(s.describe("parcel.timeout", false));
System.out.println(s.describe("parcel.verbose", false));
System.out.println(s.describe("parcel.api.key", true));
System.out.println("-> would call " + url + " up to " + (retries + 1) + " times, "
+ timeout.toSeconds() + "s each, verbose=" + verbose);
System.out.println("api key present: " + s.find("parcel.api.key").isPresent());
if (args.length > 0 && args[0].equals("--strict")) {
s.required("parcel.api.key"); // throws with a message that says how to fix it
System.out.println("strict mode: key found");
}
}
}
describe reports the winning source and masks anything you flag as secret. Never log the value of an API key, even at debug level; the log file lives longer than the key rotation.
Checkpoint: run it with nothing set, and you should see (using default) five times:
java -cp out dev.ggorantala.parcel.Main
parcel.api.url = (using default) [default]
parcel.retries = (using default) [default]
parcel.timeout = (using default) [default]
parcel.verbose = (using default) [default]
parcel.api.key = (using default) [default]
-> would call http://localhost:8080 up to 4 times, 10s each, verbose=false
api key present: false
Step 4: Fail fast when a required setting is missing
Goal: crash at startup with a message that tells the operator what to type.
The --strict flag in Main calls s.required("parcel.api.key"). Run it without the key:
java -cp out dev.ggorantala.parcel.Main --strict
Exception in thread "main" java.lang.IllegalStateException: Missing setting 'parcel.api.key': pass -Dparcel.api.key=... or set PARCEL_API_KEY
[... stack trace cut]
That message names both ways to supply the value. Whoever is paged at 2 a.m. doesn't have to open the source.
Run it with and without overrides
You already ran the defaults. Now mix the sources. The environment sets the URL, retries and key; the command line overrides the URL and sets the timeout and verbose flag:
PARCEL_API_URL=http://env.example PARCEL_RETRIES=5 PARCEL_API_KEY=abc123 \
java -Dparcel.api.url=http://prop.example -Dparcel.timeout=30 -Dparcel.verbose=true \
-cp out dev.ggorantala.parcel.Main
parcel.api.url = http://prop.example [system property]
parcel.retries = 5 [environment variable PARCEL_RETRIES]
parcel.timeout = 30 [system property]
parcel.verbose = true [system property]
parcel.api.key = **** [environment variable PARCEL_API_KEY]
-> would call http://prop.example up to 6 times, 30s each, verbose=true
api key present: true
Read the brackets. The URL came from the property even though the environment variable was also set. The retry count came from the environment (5, so six attempts). The key shows as **** and its source is PARCEL_API_KEY, and the value abc123 appears nowhere in the output.
Now a bad value:
PARCEL_RETRIES=three java -cp out dev.ggorantala.parcel.Main
Exception in thread "main" java.lang.IllegalStateException: Setting 'parcel.retries' must be a whole number but was 'three'
at dev.ggorantala.parcel.Settings.parse(Settings.java:68)
[... stack trace cut]
Compare that with what Integer.getInteger("parcel.retries", 3) would have done: silently used 3. See trap 2.
Passing settings to a child process
If your program starts another program (see how to use ProcessBuilder to run commands in Java), the child inherits a copy of your environment, and pb.environment() is the copy you edit. Trap 5 below shows it.
Check the rules with code
src/test/java/dev/ggorantala/parcel/Checks.java
package dev.ggorantala.parcel;
import java.time.Duration;
import java.util.Map;
public class Checks {
static int passed = 0, failed = 0;
static void check(String name, boolean ok) {
if (ok) { passed++; System.out.println("PASS " + name); }
else { failed++; System.out.println("FAIL " + name); }
}
static void throwsIllegalState(String name, Runnable r, String mustContain) {
try { r.run(); check(name, false); }
catch (IllegalStateException e) { check(name, e.getMessage().contains(mustContain)); }
}
public static void main(String[] args) {
Map<String, String> none = Map.of();
check("envName maps dots and dashes", Settings.envName("parcel.api-key.v2").equals("PARCEL_API_KEY_V2"));
check("default when nothing is set",
Settings.of(none, none).string("parcel.api.url", "d").equals("d"));
check("env var used when no property",
Settings.of(none, Map.of("PARCEL_API_URL", "env")).string("parcel.api.url", "d").equals("env"));
check("property beats env var",
Settings.of(Map.of("parcel.api.url", "prop"), Map.of("PARCEL_API_URL", "env"))
.string("parcel.api.url", "d").equals("prop"));
check("blank property falls through to env",
Settings.of(Map.of("parcel.api.url", " "), Map.of("PARCEL_API_URL", "env"))
.string("parcel.api.url", "d").equals("env"));
check("value is stripped",
Settings.of(Map.of("parcel.api.url", " x \n"), none).string("parcel.api.url", "d").equals("x"));
check("integer parses", Settings.of(Map.of("parcel.retries", "5"), none).integer("parcel.retries", 3) == 5);
check("integer default", Settings.of(none, none).integer("parcel.retries", 3) == 3);
throwsIllegalState("bad integer fails loudly",
() -> Settings.of(Map.of("parcel.retries", "three"), none).integer("parcel.retries", 3),
"must be a whole number but was 'three'");
check("bool is case-insensitive", Settings.of(Map.of("parcel.verbose", "TRUE"), none).bool("parcel.verbose", false));
throwsIllegalState("bool rejects yes",
() -> Settings.of(Map.of("parcel.verbose", "yes"), none).bool("parcel.verbose", false),
"must be true or false");
check("seconds -> Duration",
Settings.of(none, Map.of("PARCEL_TIMEOUT", "30")).seconds("parcel.timeout", Duration.ZERO).equals(Duration.ofSeconds(30)));
throwsIllegalState("required names both ways to set it",
() -> Settings.of(none, none).required("parcel.api.key"),
"-Dparcel.api.key=... or set PARCEL_API_KEY");
String d = Settings.of(none, Map.of("PARCEL_API_KEY", "s3cret")).describe("parcel.api.key", true);
check("describe masks secrets", d.contains("****") && !d.contains("s3cret"));
check("describe names the source", d.contains("environment variable PARCEL_API_KEY"));
check("describe plain value shows it",
Settings.of(Map.of("parcel.retries", "7"), none).describe("parcel.retries", false).equals("parcel.retries = 7 [system property]"));
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
It's plain Java with no JUnit, because I couldn't download JUnit here. If you use JUnit 5, each check becomes one @Test with an assertThat; the maps-in, string-out design needs no change. A JUnit version of this is coming in how to write unit tests with JUnit 5.
javac --release 21 -Xlint:all -d out $(find src -name '*.java')
java -cp out dev.ggorantala.parcel.Checks
PASS envName maps dots and dashes
PASS default when nothing is set
PASS env var used when no property
PASS property beats env var
PASS blank property falls through to env
PASS value is stripped
PASS integer parses
PASS integer default
PASS bad integer fails loudly
PASS bool is case-insensitive
PASS bool rejects yes
PASS seconds -> Duration
PASS required names both ways to set it
PASS describe masks secrets
PASS describe names the source
PASS describe plain value shows it
16 passed, 0 failed
To prove the checks can fail, I changed Settings.find so the property branch never runs (if (false)) and recompiled. Seven of the sixteen checks failed, including "property beats env var", then I put it back. A check you've never seen fail isn't telling you much.
Why a property beats an environment variable
System.getenv() is a snapshot of the environment the operating system gave the JVM process at launch. The JVM copies it once into an unmodifiable map. There is no portable Java way to change it afterwards, which is why trap 4 can't work. System properties are different: they're a live Properties table inside the JVM, filled at startup from built-ins like java.version, from -D flags, and you can change it with System.setProperty while the program runs.
That difference is why the order in Settings makes sense. A -D flag is an explicit argument to this launch. An environment variable is ambient, inherited from the shell, the container spec or the CI job. When both exist, the explicit one is the one somebody typed on purpose.
Environment variable names are case-sensitive on Linux and macOS and case-insensitive on Windows. My check run on Linux confirms the first half: parcel_api_url=http://low was ignored and the default was used. I didn't run Windows. That's why the converter in Settings always produces the upper-case form, and why you should never rely on two variables differing only by case.
For the deeper picture of what happens when a process is created, the ProcessBuilder article linked above covers inheritance of the environment.
Mistakes I made on purpose
I forced each of these. Run Traps.java to see them. It's the one file in the project that's only there to show you what goes wrong:
src/main/java/dev/ggorantala/parcel/Traps.java
package dev.ggorantala.parcel;
import java.io.IOException;
import java.util.List;
public class Traps {
public static void main(String[] args) throws Exception {
System.out.println("-- trap 1: Boolean.getBoolean reads a PROPERTY NAMED 'true'?");
System.setProperty("parcel.verbose", "TRUE");
System.out.println("Boolean.getBoolean(\"parcel.verbose\") with TRUE = " + Boolean.getBoolean("parcel.verbose"));
System.setProperty("parcel.verbose", "yes");
System.out.println("Boolean.getBoolean(\"parcel.verbose\") with yes = " + Boolean.getBoolean("parcel.verbose"));
System.out.println("Boolean.getBoolean(\"true\") = " + Boolean.getBoolean("true"));
System.out.println("-- trap 2: Integer.getInteger swallows a bad number");
System.setProperty("parcel.retries", "three");
System.out.println("Integer.getInteger = " + Integer.getInteger("parcel.retries", 3));
System.out.println("-- trap 3: empty -D value is an empty string, not null");
System.out.println("[" + System.getProperty("parcel.empty") + "]");
System.out.println("-- trap 4: setProperty does not change the environment, and env can't be changed");
System.setProperty("PARCEL_API_URL", "http://prop.example");
System.out.println("getenv(PARCEL_API_URL) = " + System.getenv("PARCEL_API_URL"));
System.out.println("-- trap 5: a child process gets a COPY of the environment");
ProcessBuilder pb = new ProcessBuilder(List.of("sh", "-c", "echo child sees PARCEL_API_URL=$PARCEL_API_URL"));
pb.environment().put("PARCEL_API_URL", "http://child.example");
pb.redirectErrorStream(true);
Process p = pb.start();
System.out.print(new String(p.getInputStream().readAllBytes()));
p.waitFor();
System.out.println("parent still sees = " + System.getenv("PARCEL_API_URL"));
}
}
java -Dparcel.empty= -cp out dev.ggorantala.parcel.Traps
-- trap 1: Boolean.getBoolean reads a PROPERTY NAMED 'true'?
Boolean.getBoolean("parcel.verbose") with TRUE = true
Boolean.getBoolean("parcel.verbose") with yes = false
Boolean.getBoolean("true") = false
-- trap 2: Integer.getInteger swallows a bad number
Integer.getInteger = 3
-- trap 3: empty -D value is an empty string, not null
[]
-- trap 4: setProperty does not change the environment, and env can't be changed
getenv(PARCEL_API_URL) = null
-- trap 5: a child process gets a COPY of the environment
child sees PARCEL_API_URL=http://child.example
parent still sees = null
- Trap 1:
Boolean.getBooleandoesn't parse a string. It reads a system property by that name and returnstrueonly if its value is "true" ignoring case.-Dparcel.verbose=yesgavefalsewith no complaint, andBoolean.getBoolean("true")isfalsebecause there's no property calledtrue. Cause: the name is misleading. Fix: useSettings.bool, which rejects anything but true or false. - Trap 2:
Integer.getIntegerswallows a bad number.-Dparcel.retries=threereturned the default3. Cause: it catches the parse error and returns the default. Fix: parse yourself and throw, asSettings.integerdoes. - Trap 3: an empty
-Dvalue is an empty string, notnull.-Dparcel.empty=printed[]. AgetProperty(...) != nullcheck treats that as "set". Fix: treat blank as missing, asfinddoes. - Trap 4:
System.setProperty("PARCEL_API_URL", ...)doesn't create an environment variable.getenvstill returnednull. Cause: they're separate tables. Fix: nothing. If you want an environment variable in a child process, set it on theProcessBuilder. - Trap 5: a child process gets a copy. The child printed
http://child.example, and the parent still sawnull. That's the correct behaviour; it only surprises people who expect the parent to change.
Compile-time errors worth recognizing, from a scratch file I wrote to break things:
E.java:1: error: incompatible types: String cannot be converted to int
That's int n = System.getProperty("parcel.retries");, which won't compile because getProperty returns a String. The System.getenv().put(...) line in the same file does compile, and only throws UnsupportedOperationException at run time. Compilers can't tell you the map is read-only.
Other snags to expect:
- Your IDE run doesn't see the variable you exported in a terminal. IDEs launched from a desktop icon don't inherit your shell's
export. Set the variable in the run configuration (paths above) or start the IDE from the shell. - The value has a trailing newline or space. I tested
PARCEL_API_URL=$'http://x\n';Settingsstripped it. Withoutstrip()the URL would have ended in a line break. - Names with dots or dashes in a shell.
export parcel.api.url=xis rejected by bash. That's why environment names use underscores.
Before this reads real secrets
- Pass secrets by environment variable or a mounted file, not by
-Dflags. Flags show up inpsoutput for anyone on the machine. I didn't test that here; check your OS's process-listing docs. - Don't dump
System.getenv()orSystem.getProperties()into a log. Both contain things you didn't intend to publish. TheSettings.describeapproach logs only the keys you ask about. .propertiesfiles andapplication.ymlare a third source worth adding once the list of settings grows. The same precedence idea works: add one more lookup function. For reading a file, see how to read and write files with NIO.2.- In Spring Boot, relaxed binding already maps
PARCEL_API_URLtoparcel.api.urlfor you. I didn't build that here, so check the Spring Boot reference for the exact rules for your version. - Containers: pass variables with
-e NAME=valueor an env file in Docker and Podman. I didn't run a container for this article. - Java 17+ is fine for everything above.
Settingsuses nothing newer than Java 11 (String.isBlank,strip). On Java 8, replace those two methods.
If a value should be retried or cached, look at how to implement retry logic with exponential backoff next.
Keep this list
- Read a setting through one class, never
System.getenvscattered around. - Decide the order once: property, then environment variable, then default.
- Treat blank as missing and strip whitespace.
- Parse numbers and booleans yourself and fail with the setting's name.
- Never print a secret; print only where it came from.
- Inject the lookups so tests don't need real environment variables.
- Remember that the environment is read-only in Java; only a child process gets changes.
Questions about Java settings and environment variables
How do I read an environment variable in Java?
Call System.getenv("NAME"). It returns the value as a String, or null if the variable isn't set. System.getenv() with no argument returns an unmodifiable map of all of them.
How do I set an environment variable in Java?
You can't change your own process's environment. System.getenv() is read-only. You can set one for a child process through ProcessBuilder.environment(), or set it outside Java before starting the JVM.
What's the difference between System.getenv and System.getProperty?getenv reads operating-system environment variables the JVM inherited. getProperty reads JVM system properties, which come from -D flags, built-ins like java.version, and System.setProperty calls.
How do I pass a -D value to a Java program?
Put it before the class or -jar: java -Dparcel.timeout=30 -cp out Main. After the class name it becomes a program argument instead.
Is it safe to put secrets in environment variables?
Safer than -D flags, since flags appear in process listings. Still, avoid logging them and keep them out of images. A mounted secret file or a secret manager is better for production.
References
- Java SE 21 API:
java.lang.System(getenv,getProperty,getProperties,setProperty),java.lang.Boolean.getBoolean,java.lang.Integer.getInteger,java.lang.ProcessBuilder.environment(docs.oracle.com/en/java/javase/21/docs/api/) - Java SE 21
javalauncher documentation for the-Doption