1. Three configuration channels: env, -D, and args
When you first hear “env vars”, “system properties”, and “application args”, the brain naturally tries to simplify: “what’s the difference, they are all just values anyway”. And that is true — but only halfway. The difference appears exactly at the moment when the application stops living “inside the IDE on your machine” and starts living as a process in a container: you suddenly see that values can be passed in different places, and they override one another too.
To avoid turning configuration into a guessing game, it helps to build a very pragmatic picture right now:
- env vars — what you usually pass to the container “from the outside” as part of the environment;
- system properties — what you pass to the JVM via -D... (that is, the JVM’s “internal dictionary”);
- application args — what you pass to the Spring Boot application itself as --key=value arguments.
Spring Boot can read all three, but being able to read them does not mean “you will never mix them up”. You will. We all have. And that is normal, as long as you have a clear mental model.
You can pass both spring.profiles.active and a simple training app.mode through the same channels. What matters here is the delivery mechanism, not which key is more convenient right now.
Below is a simple picture of how these channels enter the application:
flowchart TD A["Host: developer terminal"] -->|docker run -e ...| B[Container env vars] A -->|java -D...| C[JVM system properties] A -->|--key=value| D[Spring Boot application args] B --> E[Spring Boot property sources] C --> E D --> E E --> F[Final property value]
Notice an important point: the application code should almost never know where the value came from. It should work with the result. That is exactly why in examples we often read values through @Value("${...}"): it is a quick way to see “what won”.
2. Env vars: the container’s “love at first launch”
Env vars (environment variables) are popular in the container world for a very simple reason: they are easy to pass from the outside and easy to change without rebuilding. You are changing not the image, but the startup parameters. That matches our rule perfectly: “same image, different runtime config”. And yes — this is where newcomers usually make their first big mistake: they try to bake a mode into the Dockerfile via ENV, and then wonder why they have to copy the Dockerfile for each mode.
How env vars reach a Spring Boot application
Inside a container, environment variables are just a set of KEY=VALUE strings. Spring Boot reads them and can map them to property names (how exactly to map them is a separate topic in the next lecture about relaxed binding; today, the idea and the practice matter to us).
Let us take one very container-friendly example — the port. In Spring Boot the property is server.port, and the “typical” environment variable is SERVER_PORT. This is exactly the kind of case where things look logical even without deep naming rules.
If you already have a small runtime handle, one field is enough for this check:
@Value("${server.port}")
private String port;
You can expose it the same way as the rest of the diagnostic values. What matters here is not a new controller, but the fact that Spring honestly shows the winning server.port.
Now a local launch outside Docker can look like this: you set an env var in your OS and start the application in the usual way. On Linux/macOS it will be one command, on Windows another, but the idea is the same: the value must end up in the process environment that starts Java.
If you are launching in Docker, you pass the env var to the container:
# --rm: remove the container after stop (convenient for local experiments)
# -p 8080:8080: publish the port from the host to the container (Docker networking)
# -e SERVER_PORT=8080: environment variable inside the container (application config)
docker run --rm \
-p 8080:8080 \
-e SERVER_PORT=8080 \
docker-java-catalog-service
There is a subtle point here that almost every beginner trips over: -p 8080:8080 publishes the container port to the outside, but does not change the port on which the application listens inside the container. That means “port publishing” and “setting server.port” are two independent things (and that is not a bug, but the normal Docker networking model).
Env vars and Docker startup
If we are being honest, env vars are “the laziest way to do the right thing”. They work well with how the container starts:
- you do not rewrite ENTRYPOINT;
- you do not complicate the Java launch command;
- you just say: “container, here are your values”.
In a Docker context, this is also handy because env vars are easy to read by eye: you open the launch command and see what happened. That is especially valuable when you have one image and many modes.
And here comes the typical anti-pattern: “let us put this into the Dockerfile via ENV”. Technically it works, but you quietly turn the image into an “environment”. And we are trying to do the opposite: the image is code, the environment is runtime.
3. System properties (-D...): values inside the JVM
System properties are properties that live inside the JVM and are usually set like this: -Dkey=value. To a beginner they look like “just another way to pass the same thing”, but in container life they have their own roles and their own traps.
The main difference in feeling is this: env vars are “we put a note on the container’s desk”, system properties are “we put a note straight into the JVM’s pocket before startup”. Spring Boot can read both, but in conflicts system properties usually have higher priority (we already discussed this at the level of the general priority ladder).
Starting with -D... locally
Let us imagine that we are launching an already built jar. Simplified command:
java -Dserver.port=8082 -jar app.jar
If application.yml also had server.port: 8080, and you passed -Dserver.port=8082, then the application will start on 8082, because the system property overrides the file-based config.
Do not confuse a “system property” with “any JVM option”. For example, -Xmx256m is a JVM memory setting, but not a system property. -D... is specifically a “key/value” pair that will end up in System.getProperties().
System properties in a container without editing the Dockerfile
In the container world, you do not want to rewrite the Java startup command every time. And this is where a practical thing appears: the JVM reads the JAVA_TOOL_OPTIONS environment variable and appends it to its own options. This is convenient because you still live in the “env var world”, but in fact you influence the JVM level.
Example (purely demonstrational):
# JAVA_TOOL_OPTIONS will be read by the JVM and added to the startup parameters
# Here we create the system property -Dserver.port=8082 via an env var
docker run --rm \
-p 8082:8082 \
-e JAVA_TOOL_OPTIONS="-Dserver.port=8082" \
docker-java-catalog-service
From the point of view of Spring Boot, this will look like “server.port came from system properties”.
There is an important psychological detail: you seem to have “passed an environment variable”, but in reality you created a system property with higher priority. That is how situations like “I passed SERVER_PORT, but somehow it is ignored” happen. The answer is often this: somewhere in JAVA_TOOL_OPTIONS (or directly in the java command) you already set -Dserver.port, and it overrode the env var.
When system properties are appropriate
In normal development practice, there is often an unwritten rule: “everything related to Spring Boot configuration goes through env vars or --... arguments, and system properties are kept for JVM-related things”. That is not a law of nature, but it at least reduces the chance of chaos.
If you are a beginner, you can adopt a simple discipline: env vars are the main channel for Docker startup, system properties are a tool for situations where you really need to influence the JVM level or you explicitly want priority “above env vars”.
4. Application args (--key=value): high priority
Application args are the arguments you pass to the application at startup, and Spring Boot can turn --key=value arguments into properties. That is why commands like --server.port=9090 are so popular in the Boot world. They read like “do it this way for this run”.
This is where beginners often get confused, because externally -Dserver.port=9090 and --server.port=9090 look almost the same, but they are completely different paths. The first goes through the JVM, the second through application arguments.
Starting a jar with application args
A classic example:
# After -jar, anything that starts with -- will be treated as application arguments (Spring Boot)
java -jar app.jar --server.port=8083 --app.mode=standalone
Spring Boot will take these values and apply them as properties. If somewhere lower down (in application.yml or env vars) values also existed, application args will almost always be higher in priority.
Starting a Docker container with application args
This is where it gets especially interesting (and useful), because it is directly tied to how your ENTRYPOINT and CMD are set up.
If your Dockerfile is written in the normal style with an exec-form ENTRYPOINT, for example:
# Exec-form ENTRYPOINT: anything you append to docker run after the image name
# becomes arguments for java (and therefore for Spring Boot)
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
...then everything you add at the end of docker run ... image ..., will be appended as arguments to that ENTRYPOINT.
Example:
# Everything after the image name docker-java-catalog-service is already application args
docker run --rm \
-p 8083:8083 \
docker-java-catalog-service \
--server.port=8083 --app.mode=standalone
So in effect we made “container startup” and “Boot arguments” one line. It looks a little longer than env vars, but it is often very convenient for a one-off “let us test a hypothesis” run, without changing either the Dockerfile or the environment variables.
Application args and configuration ghosts
If you once saved a startup command somewhere (in terminal history, in an IDE run configuration, in a script), added --server.port=..., and then forgot about it, you will stare painfully at application.yml and think Spring Boot is ignoring you.
It is not ignoring you. It is simply following the rule honestly: “the strongest value wins”. And application args are a very strong channel.
5. Interaction between env vars, -D, and args
When we say “source priority”, it is important to understand one thing: priority works not by entire file, but by key. That means you can have a dozen properties, and each can come from a different channel. In the end you get a “mixed salad”, but not in a bad sense: this is the normal model of external configuration.
Let us fix a simplified ladder (exactly the one we need for this day of the course, without rare sources and subtle nuances):
| Channel | Example | Where it is set | Priority (within today’s model) |
|---|---|---|---|
| File-based config | |
|
lower |
| Env vars | |
|
above the file |
| System properties | |
or |
above env vars |
| Application args | |
|
above everything listed |
Now let us imagine a situation (yes, this is a “mental experiment”, but it is very realistic):
- application.yml contains server.port=8080;
- you start the container with -e SERVER_PORT=8081;
- you also add -e JAVA_TOOL_OPTIONS="-Dserver.port=8082";
- and at the end you pass --server.port=8083.
8083 will win, because application args are the highest-priority source in this chain.
To see the conflict live, the same diagnostic handle that already shows mode and server.port is enough. You change the way the value is passed, and you check the same final result.
And one important observation: the diagnostic handle does not know where app.mode came from. It sees the final result. That is normal. That is even good: we do not want to write code that says “if it came from env vars, do one thing; if it came from args, do another”. We want to write code that works with the final configuration.
A common Docker pitfall with ports
This topic already appeared earlier in the course, but on the day about configuration it starts to bite especially hard. A beginner changes SERVER_PORT, but forgets to change -p, or vice versa. As a result, the symptoms look like “the application is not starting”, “the container is broken”, “Spring Boot is not listening on the port”, while in reality everything works — you are just looking in the wrong place.
If the application inside the container listens on 8083, then the publication should look like -p 8083:8083 (or any hostPort:8083, if you want to change the port on the host). For example:
# Inside the container, the application listens on 8083 (SERVER_PORT=8083)
# Outside (on the host), we publish it as localhost:9090
docker run --rm \
-e SERVER_PORT=8083 \
-p 9090:8083 \
docker-java-catalog-service
In this example the application inside the container listens on 8083, and from the host you access localhost:9090. That can be convenient, but only if you clearly understand what you are doing.
And this is where a useful rule is born for a training project: it is better to keep the application’s internal port stable, and if needed, change the external publication port. In other words, change -p more often than server.port. But understanding both approaches is essential, because life is not always ideal.
6. Typical configuration mistakes
Mistake #1: confusing -D... and --... and treating them as the same thing.
From the outside, both options look like “I set server.port”, but the first one is a JVM system property, while the second one is a Spring Boot application argument. As a result, you can accidentally get a different priority behaviour, and also a different way of passing it in Docker (for example, JAVA_TOOL_OPTIONS can override everything invisibly). The fix is simple: keep in mind that -D is the JVM’s pocket, and -- are the words you told Spring Boot at startup.
Mistake #2: expecting a change in application.yml to always have an effect.
If you have ever started the application with --server.port=..., you may see no effect from editing the file at all. And this is not magic and not “Boot being weird”; it is the honest order of property sources. The simplest way to debug is to temporarily print the final value through a small endpoint like /api/runtime and make sure you are really looking at the final configuration, not at the file “as written”.
Mistake #3: “I published the port with -p, so server.port must have changed too”.
Docker does not touch your process and does not retrain Spring Boot to listen on another port. -p is about Docker networking, and server.port is about the application. If you changed one and left the other unchanged, the container may be alive, but you will be knocking on a closed door, sometimes with great confidence. Discipline helps: we fix what the application listens on, and separately we fix what we publish outward.
Mistake #4: setting the same property through env vars, JAVA_TOOL_OPTIONS, and --..., and then wondering “who won”.
At some point the beginner starts passing the value through every method “just in case”. That almost guarantees a situation where you can no longer explain the behaviour. In practice, it is useful to choose one main channel “by default” for your project (often env vars for Docker startup), and keep the others as conscious tools for rare cases.
Mistake #5: trying to “encode the environment” in the Dockerfile via ENV.
ENV in the Dockerfile is not evil, but it is an image default. When you write SPRING_PROFILES_ACTIVE=postgres there, you make your image “postgres by default”. Then you need standalone, and you start either rebuilding the image, or copying the Dockerfile, or writing hacks. And we are building a model where there is one image, and modes come at startup. So it is better to keep ENV as a “reasonable default”, and pass specific modes from the outside.
GO TO FULL VERSION