CodeGym /Courses /Docker for Spring /Runtime and development images

Runtime and development images

Docker for Spring
Level 10, Lesson 0
Available

1. Image roles: runtime and development

A very natural thought after all the improvements sounds like this: "We finally made a good runtime image. It is clean, multi-stage, just like the best of them... So we will use it both for development and for debugging." In practice, this is exactly the moment when quiet chaos appears in a project: the image starts accumulating settings "just in case", and then you no longer remember what is a necessity and what is an accidental compromise.

Buildpacks, which we have already seen, are still there: if you just need a ready-made runtime image, that is still a normal path. But the debug scenario has a different pain point: you need to add convenience for diagnostics without polluting the ordinary container startup. This is where a managed Dockerfile path is needed, where clean runtime and development paths are clearly separated.

It is important to catch one simple idea here: development and normal startup have different goals. A runtime image (our "default") should be predictable, calm, and as close as possible to the "normal service startup" experience. A development image is a tool that helps you figure out what is happening when something goes wrong. It can be "heavier", "noisier", and even a little less pretty — because beauty is not the top priority here.

To avoid turning this into a religion ("everything must be minimal" versus "everything must be convenient"), it is useful to compare the two modes as two different roles of the same service:

Criterion Runtime image (normal startup) Development image (debug-oriented)
Main goal Start the service predictably Diagnose and debug conveniently
Size and "extras" Smaller and cleaner is better More is acceptable if it helps
Ports Usually only HTTP HTTP + separate debug port
JVM parameters Minimum, only what is needed May include JDWP and diagnostic options
Risk of "accidentally shipping to prod" Minimal Higher, so it should be separated
What we run app.jar app.jar

It is important not to confuse the roles: a development image is not an "improved version of the runtime image". It is a different tool. Like a drill and a screwdriver: both rotate, but if you try to drill with a screwdriver "on a whim", it will be very painful for the screws and for your self-esteem.

2. The runtime image: predictable startup

A runtime image is your main container-based startup method. Even if you are still running everything locally and have not smelled production yet, this is the image that should remain as calm as possible: it is meant for the situation "I want to start the service and be sure it starts the same way on my machine, on a teammate's machine, and in CI". The fewer surprises there are in the runtime image, the easier it is to diagnose real problems later.

A good runtime image usually contains nothing "for debugging". Not because debugging is bad, but because debug settings affect process behavior. They open additional ports, sometimes change timing, and also create the illusion that "this is how it is supposed to be". The worst part is when the team gets used to "debug always on" and then is suddenly surprised that the service behaves differently in another environment.

In practice, a runtime image is a continuation of the idea "stable startup method + changeable arguments". That is, the startup mechanism itself lives in ENTRYPOINT/CMD, while the changeable parts (port, profile, application parameters) should be passed when the container starts, not baked into the image. And this is where the fine line appears: "pass a port/profile" is normal runtime configuration; "enable JDWP and open a debug port" is already the "I am debugging" mode, and it deserves a separate path.

Another practical reason to keep the runtime image clean is security and discipline. A debug port (classically 5005) is an entry point for the debugger. In everyday local development, it almost always lives on localhost, and nothing bad happens. But if such an image accidentally ends up somewhere where ports are accessible to others, you are literally leaving a "door into the JVM" open. And in the world of infrastructure, open doors usually do not just creak — someone eventually walks through them.

3. The development image: diagnostics

A development image is the case where we honestly say: "Yes, right now convenience matters more than minimalism." That is normal. Debugging in a container is often needed precisely because the problem appears in the container environment: a different file path, a different network setup, different memory, a different startup order, or simply "it works on my machine, but not in the container". And in such moments, what you want is not philosophy, but the ability to set a breakpoint and see what is actually happening.

By a development image, it is convenient to mean a debug-oriented image: an image in which settings for remote JVM debugging are intentionally enabled. It is important not to be scared by the word "remote": in Docker, this is "remote" even when everything is on one machine. The JVM inside the container is a separate process in separate isolation, and the IDE connects to it over the network as to a "remote" process.

From a technical point of view, the development mode usually differs in two ways. First, by the base image: for debugging it is more convenient to use a JDK-based image, because it definitely has everything the debugger needs, and there is less chance of running into weird "something is missing in the minimal runtime" issues. Second, by the JVM startup command: to the usual -jar app.jar, parameters are added that enable the JDWP agent and make the JVM listen on the debug port.

The most important thing that keeps the development image "within engineering" is the rule: the code and the jar stay the same. We do not create a separate "debug build" of the application, we do not add branches like if (DEBUG) ... to the Java code, and we do not create a "second reality" of the project. We only change the container startup method and the environment in which it is more convenient to investigate the problem.

4. Stage roles: builder, dev, runtime

When this is implemented in a Dockerfile, the roles usually split very simply:

  • builder builds bootJar.
  • runtime runs this jar as a normal service.
  • development runs the same jar in a more convenient diagnostics mode.

For now, only this boundary matters: there is one artifact build, but there may be several startup variants. Then debug settings do not spread into the main image and do not become the "new normal".

5. A mini example: how runtime quietly turns into debug

We already have a clean runtime path: a normal ENTRYPOINT ["java"] and CMD ["-jar", "app.jar"]. It is more useful to look at the place where this path most often gets broken.

FROM eclipse-temurin:25-jdk AS runtime

WORKDIR /app
COPY app.jar app.jar

# This image "exposes" both HTTP and the debug port — that is a clear signal
# that runtime is no longer clean.
EXPOSE 8080 5005

ENTRYPOINT ["java"]

# Anti-pattern: JDWP is enabled all the time, so the container always listens on the debugger port.
CMD ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]

It looks "almost the same", but the meaning is already different. Such an image now always listens on the debug port, always starts in debug mode, and that becomes your new "normal". The problem is not that JDWP will "break everything" (often it really does not), but that you lose a clean baseline. Any diagnosis turns into guesswork: is the service behaving this way because of the code, because of the container environment, or because you are always running the JVM with extra agents?

And there is also a human factor: if debug is enabled "by default", you stop distinguishing between modes. And when modes are not distinguished, a "fragile habit" appears in the project. Guess who usually breaks first when habits are fragile? Right: the person who joined the team yesterday and simply started the service "as in the README".

6. Typical mistakes when working with images

Even when the idea of "two images for two tasks" becomes clear, the most unpleasant mistakes usually happen not because of Docker, but because of haste and the desire to "make it work somehow, as long as it debugs". The irony is that this desire is exactly what destroys predictability — and predictability is also needed for debugging.

Mistake #1: treating the development image as "more correct" and making it the only one.
It is easy for a beginner to get the feeling that if an image has a debug port and "everything is more convenient", then that must be the right image. In reality, it is just a different mode. The runtime image exists precisely so that you always have a clean control run without "diagnostic crutches".

Mistake #2: confusing the builder stage and the development stage.
The builder stage builds the jar and does not have to be convenient for running. The development stage runs the jar and does not have to contain source code or Gradle. When these roles are mixed, you get a bloated image that both builds and runs, and does so from scratch every time. It kind of "works", but becomes slow, large, and unreadable.

Mistake #3: enabling debug settings directly in the runtime image "so you do not forget".
The desire "not to forget to enable debug" is understandable, but the solution is not to bake JDWP into the main CMD, but to create a separate development path. Otherwise you will definitely forget the opposite: that debug is enabled at all. That is no longer funny, especially when the debug port remains open where you did not expect it.

Mistake #4: changing application code for the debug mode instead of changing the container startup.
Sometimes a developer adds branches like "if DEBUG, then log everything" to the Java code, or even starts a second port directly from the application. That is a bad path: you are learning not how to manage packaging and startup, but how to bury infrastructure modes inside business code. Here it is important to keep the boundary: one jar, one codebase, only the container startup method changes.

Mistake #5: using the "development image" as a replacement for the principle same image, different runtime config.
A development image should not become a way to "produce images for every value". Port, profile, application parameters — these are still the responsibility of runtime configuration at container startup. A development image exists only because you are changing not the functional parameters of the service, but the JVM operating mode itself for debugging.

1
Task
Docker for Spring, level 10, lesson 0
Locked
One `bootJar`, two image roles
One `bootJar`, two image roles
1
Task
Docker for Spring, level 10, lesson 0
Locked
Different declared ports without changing the code
Different declared ports without changing the code
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION