1. One image — many runs
We already have the image. Next, the puzzle fits together like this: the mode should not live in the Dockerfile, the final property value is determined by source priority, values arrive through env vars, -D and --..., and env var names need to be translated without guessing. Now all of this needs to be assembled into one runnable workflow: take one image and start it in two runtime scenarios without changing the artifact itself.
In our commands, the mode switch will be spring.profiles.active. Here, profiles are used as an already existing project-specific switch: standalone/postgres. We do not need to unpack the whole profile mechanism right now; it is enough to see the more important thing: even such a Spring mechanism remains part of runtime configuration, not a reason to build a new image.
Imagine that we have an image for a training service:
docker-java-catalog-service:local (or any tag you use — the name is not the point).
And the service has two modes:
- standalone — the service works on its own, without PostgreSQL, using in-memory storage.
- postgres — the service works with PostgreSQL (through JPA/Flyway), meaning it expects a reachable datasource.
Keep this mental picture in mind:
flowchart TD
Image["Docker image: docker-java-catalog-service:local<br/>(immutable)"]
Image -->|run #1<br/>SPRING_PROFILES_ACTIVE=standalone| C1["Container: catalog-standalone"]
Image -->|run #2<br/>SPRING_PROFILES_ACTIVE=postgres<br/>+ datasource vars| C2["Container: catalog-postgres"]
C1 --> A1["API responds in standalone mode"]
C2 --> A2["API responds in postgres mode (if DB is reachable)"]
2. A little truth window: runtime endpoint
If in the previous experiments you were already temporarily printing individual properties, now it is time to put this into one stable /api/runtime endpoint. It will become the main effective config check for the commands below.
When you start a container, it is very easy to trust the docker run command itself: “I wrote SPRING_PROFILES_ACTIVE=postgres, so that must be it.” In practice, things are more honest and stricter: the value may have been overridden, the profile may not have activated, and the port may have come from another source. So instead of a pile of one-off endpoints, we will build one short runtime probe that shows the effective config as a whole.
package com.example.catalog.ops.web;
import java.util.Arrays;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class RuntimeController {
private final Environment env;
RuntimeController(Environment env) {
this.env = env;
}
@GetMapping("/api/runtime")
String runtime() {
String[] activeProfiles = env.getActiveProfiles();
String profiles = activeProfiles.length == 0
? "<none>"
: String.join(",", activeProfiles);
String effectiveMode = Arrays.asList(activeProfiles).contains("postgres")
? "postgres"
: "standalone";
String port = env.getProperty("server.port", "8080");
return "mode=" + effectiveMode + ", profiles=" + profiles + ", port=" + port;
}
}
There are two different things here, and it is important not to mix them up. profiles shows what Spring actually activated. mode is already the project-level interpretation of the run: if postgres is enabled, we consider the mode to be postgres; otherwise we stay in standalone. So mode=standalone in the response does not mean “Spring magically activated the standalone profile by default”; it is just a short way to say “we started without postgres mode.”
This single endpoint is enough for all commands below. We will read the responses roughly like this: mode=postgres, profiles=postgres, port=8081.
3. Profile and port: two axes
A very human beginner mistake is to think like this: “standalone means port 8080, postgres means 8081.” While you are alone and while you have one laptop, this may even “work.” But as soon as you start two containers side by side, add tests, or simply want to change the port quickly, this connection starts to get in the way. Port and profile are different properties, and it is better to keep them independent.
In our run commands, the profile is the project switch, and the port is a separate launch axis.
Let us fix this in a small table. It is simple, but it disciplines the brain better than a thousand smart words.
| What we change | Example property | Why we change it at all | Can it be changed without rebuilding the image |
|---|---|---|---|
| Project mode (standalone/postgres) | spring.profiles.active | Enable/disable the project's infrastructure mode | Yes |
| Service port | server.port | Run several instances side by side, avoid port conflicts | Yes |
| Database address | spring.datasource.url | Connect to a different DB/host | Yes |
| DB login/password | spring.datasource .username/password | Connect to a specific database | Yes |
And here is the key point of the lecture. If only one cell in this table changes, rebuilding the image is pointless. The image is code. Configuration is the conditions under which the code lives.
Another useful “sanity check” sounds like this: if you can describe the change with words like “I need to run the same service, but...” (on a different port, with a different profile, with a different DB address), then this is almost always runtime configuration. If you say “I need the service to behave differently because I rewrote the code/dependencies,” that is already about the build.
4. Running the image in standalone mode
Now comes the moment where Docker finally stops being philosophy and becomes something you can verify with curl. The standalone mode is valuable because it lets you run the service without external dependencies. This is a “mode without excuses”: if it does not start, the problem is definitely not PostgreSQL, not the network, not service names. Most likely the problem is in the launch configuration or in the fact that you are starting the wrong thing.
Here SPRING_PROFILES_ACTIVE is just another runtime key. It arrives through the same env vars as SERVER_PORT, and does not require a separate image.
Option via env vars
Suppose we want:
- the container listens on 8080 internally;
- we publish this port to the outside;
- we enable the standalone profile.
The command might look like this:
# --rm: remove the container after it stops
# --name: so we do not mix up what is currently running
# -p: publish the port (host:container)
# -e: set runtime configuration (profile/port) without rebuilding the image
docker run --rm \
--name catalog-standalone \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=standalone \
-e SERVER_PORT=8080 \
docker-java-catalog-service:local
Now we check our “truth window”:
# Check the effective profile and port values that actually came up after startup
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
If you see mode=standalone, profiles=standalone, port=8080, then the profile and port are both correct. We did not rebuild the image. We did not touch the Dockerfile. We just supplied different values at container startup.
Option via application arguments
Because our Dockerfile (by course convention) launches the application via ENTRYPOINT in exec form, you can append arguments after the image name — and they will arrive as application args (--key=value).
For example, like this:
# Arguments after the image name will be passed to Spring Boot as application args (--key=value)
docker run --rm \
--name catalog-standalone \
-p 8080:8080 \
docker-java-catalog-service:local \
--spring.profiles.active=standalone \
--server.port=8080
And again, we check:
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
The choice between env vars and application args is not a religion. It is a matter of convenience and team agreement. In the Docker/Compose world, env vars are often more convenient as a baseline (especially when there are many parameters), but for a one-off run, arguments work perfectly well too.
5. Running the image in postgres mode
By meaning, the postgres mode should connect to PostgreSQL. Where that PostgreSQL lives is not important right now. It may be on your machine, on another server, or in a neighbouring container. Our task is more modest: to show that switching modes does not require a new image.
For the postgres mode to be possible, we need to pass at least:
— the active postgres profile,
— the port (so we do not conflict with the standalone container if we start both),
— datasource settings: URL, username, password.
Let us show the launch command through env vars (the more typical option for containers):
# Here we set the profile and datasource through env vars — this is exactly runtime configuration.
# Important: <DB_HOST> must be reachable FROM the container, not "as you are used to from the host machine."
docker run --rm \
--name catalog-postgres \
-p 8081:8081 \
-e SPRING_PROFILES_ACTIVE=postgres \
-e SERVER_PORT=8081 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://<DB_HOST>:5432/catalog \
-e SPRING_DATASOURCE_USERNAME=catalog \
-e SPRING_DATASOURCE_PASSWORD=catalog \
docker-java-catalog-service:local
Notice: <DB_HOST> is not “localhost by default”, but a specific host that is reachable from the container. Today we are deliberately not diving into the network model of the multi-container world, so we write this as a parameter that comes from outside.
If the database is available and the parameters are correct, the check will be just as simple:
curl http://localhost:8081/api/runtime
# mode=postgres, profiles=postgres, port=8081
If the database is not available, the application will most likely fail on startup (and that is normal for the postgres mode). The important thing is that you still see the principle: you did not build a separate image “for Postgres.” You ran the same image with a different set of input values.
There is a good “mental test” here: imagine that you published the image to a registry and a colleague uses it. They may have a different DB, a different password, a different port. If they have to rebuild your image just for the password, that is not “they are bad”; that is “the image is designed incorrectly.” The password and URL should arrive at startup, not live in the Dockerfile.
6. Two containers from one image
The most visual demonstration of “same image, different runtime config” is running two containers at the same time from one and the same image. This is the moment when the brain stops “doubting” and starts believing: one artifact really can live in different modes. In everyday development, this is often used for tests, migrations, experiments, and simply for clean separation of “how the service lives here and now.”
Imagine two commands side by side:
# First container: standalone (its own profile and its own port)
docker run --rm --name catalog-standalone -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=standalone -e SERVER_PORT=8080 \
docker-java-catalog-service:local
# Second container: postgres (the same image, but different profile/port and datasource settings)
docker run --rm --name catalog-postgres -p 8081:8081 \
-e SPRING_PROFILES_ACTIVE=postgres -e SERVER_PORT=8081 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://<DB_HOST>:5432/catalog \
-e SPRING_DATASOURCE_USERNAME=catalog \
-e SPRING_DATASOURCE_PASSWORD=catalog \
docker-java-catalog-service:local
Notice: the image name is the same (docker-java-catalog-service:local). Only the container launch parameters differ. Different container names (catalog-standalone, catalog-postgres) help avoid confusion, and different host ports (8080 and 8081) remove the conflict.
Verification is symmetrical too:
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
curl http://localhost:8081/api/runtime
# mode=postgres, profiles=postgres, port=8081
If you ever caught yourself thinking “I need to rebuild the image because I am changing the port/profile/DB host,” then this exercise breaks that habit fairly quickly. Rebuilding is needed when the code changes. Everything else is input values at launch time.
7. Typical mistakes when running one image in different modes
Mistake #1: making separate images for modes or encoding the mode in the image name.
catalog-service-postgres, catalog-service-standalone, catalog-postgres:1.0 — all of this first looks “convenient,” and then quickly turns into a zoo of tags and Dockerfiles. You are no longer sure that different modes run the same code, and any small change starts requiring two builds instead of one. The mode should live in the container launch parameters, and the image name should only say what service it is.
Mistake #2: baking the profile into the Dockerfile through ENV and then treating it with a rebuild.
ENV SPRING_PROFILES_ACTIVE=postgres inside the Dockerfile looks harmless until you need standalone. After that, you either rebuild the image, or create yet another Dockerfile, or start overriding everything at once and lose transparency. If only the mode changes, then the problem is not in the build. That means you need to change the runtime configuration.
Mistake #3: mixing channels for the same key or confusing -D... and --...
In container launches, this is especially easy to do: there is already Docker CLI around, ENTRYPOINT, JVM, and Spring Boot. -Dserver.port=9090 is a JVM system property, while --server.port=9090 is a Spring Boot application argument. If you also add SERVER_PORT=8080, getting “why did the wrong one win?” becomes very easy. For one key, it is better to choose one main channel until you have a conscious reason to make things more complex.
Mistake #4: thinking that localhost in SPRING_DATASOURCE_URL means your host machine.
Inside a container, localhost points to the container itself. So jdbc:postgresql://localhost:5432/... means “Postgres should be inside this same container,” not “somewhere near your laptop.” Sometimes this works by accident, but as a baseline it is almost always a reasoning mistake.
Mistake #5: tying profile and port together as one “mode.”
As soon as you decide “postgres is always on 8081,” you are setting a trap for yourself. The port is a network-boundary agreement, the profile is enabling/disabling infrastructure configuration. They do not have to match today or tomorrow. Life becomes noticeably simpler when you keep them independent and pass them separately.
Mistake #6: losing the image vs container boundary and debugging “the wrong layer.”
An image is a recipe and an artifact. A container is a concrete run with concrete parameters. If two containers from one image behave differently, the first thing to look at is not the Dockerfile, but the effective config of the current run. That is why a short endpoint like /api/runtime is needed: it quickly answers which container is running now and with which parameters.
GO TO FULL VERSION