CodeGym /Kursy /Docker for Spring /Compose error as an application error

Compose error as an application error

Docker for Spring
Poziom 22, Lekcja 3
Dostępny

1. Compose: Spring crashes because of YAML

When you run Container-Ready Catalog Service locally through bootRun, you have a familiar worldview: if it crashed, that means the bug is in the code or in the application config. Compose breaks this habit very gently and very insidiously: you are not changing Java code, but the environment, and the application honestly crashes... so it feels like “Spring Boot is broken.” In reality, Spring here is just acting as a thermometer that shows the temperature first, but is not the cause of the cold.

In Compose you get neighbours: postgres, redis, rabbitmq. A Compose network appears, volumes appear, several Compose files appear (compose.yaml and compose.dev.yaml), and variables are substituted from .env. And now you no longer live in “one application,” but in a small town. If the water is cut off in the town (the postgres service did not come up), the first one to suffer is the one who tried to wash their hands (your application), and it will complain as if its “bathroom broke.” That is why in Compose it is so easy to confuse the place where the error appears with its root cause.

The most important practical idea here is simple: in a multi-container environment, some errors must be diagnosed from outside the application just as carefully as from inside it. If you see a stack trace in the app logs, that does not yet mean “the code is guilty.” It only means one thing: app honestly reported that it was not given what it expects from the environment.

With isolated failures, things were relatively honest: the symptom was usually close to the cause. In Compose, a separate trap appears — the error lives in the startup model, while you first see the stack trace in app. So from here on, it is important to keep two levels in mind: what Compose is going to start according to YAML and what is actually present inside the created containers.

2. YAML vs runtime: what actually started

One of the reasons a Compose error so easily masquerades as an application error is the gap between what you think you are starting and what actually happened after all the merges. In a beginner’s mind, compose.yaml is the truth, and containers are just “its execution.” In real Compose, this is more like compilation: you have source files (compose.yaml, compose.dev.yaml, .env), and the output is a “compiled” final configuration. And Compose creates containers based on that.

Here is a small (and useful) way to think about it: compose.yaml is like Java code, and docker compose config is like bytecode after compilation. You can stare at the source a thousand times and say “well, everything looks correct here,” but what will run is what came out after substitutions and merges.

In the Compose world, there are very often several reasons why “what is written” is not the same as “what was produced”:

First, multiple Compose files. In our course this is canonical: there is compose.yaml and there is compose.dev.yaml, and the second file overrides the first. If you look only at compose.yaml, you see only half the picture (roughly like reading only the odd-numbered pages of a book and wondering why the plot is strange).

Second, variable substitution. Compose can replace ${SOME_VAR} with an empty string if the variable is not set, and this creates very “plausible” errors already at the application level. And yes, the application will honestly say “I cannot configure the datasource,” but the root cause will be that the connection string became empty because of substitution.

Third, “old containers.” You may change environment or volumes in YAML, but running containers are not always automatically recreated in the way you intuitively expect. As a result, you fix the YAML, but in fact you are still looking at the behavior of the old configuration.

And here it is easy to confuse two different sources of truth. docker compose config shows the resolved startup model: what Compose will assemble from files, override, and substitutions. docker inspect and, when the container is running, docker compose exec ... printenv show the actual state of the already created container: what is inside it right now. After a YAML change, these levels may diverge: the desired config is already correct, but the container is still old. In that situation, first recreate the needed service, and only then repeat the same failing check.

That is why in Compose diagnostics you get a separate mandatory question: what exactly was applied.

3. docker compose config: merged truth

If you remember exactly one command from this lecture, let it be docker compose config. It is needed not “for beauty” and not for DevOps magic. It shows the resolved truth about the startup model: what Compose is going to assemble from files, overrides, and .env. And only after that do you compare it with the actual state of the containers so you stop arguing with your imagination.

The most important point: when you have two files, you must check the configuration in exactly the same form in which you actually start the stack. If you bring up the dev environment like this:

# Start the stack with exactly the same files we use in the real dev run
docker compose -f compose.yaml -f compose.dev.yaml up

then you should look at the “truth” like this:

# Print the final (merged) configuration after all overrides and substitutions
docker compose -f compose.yaml -f compose.dev.yaml config

This command will show you the final YAML after substitutions and merging. Then you can use your eyes to look for key things: which ports are actually published, which env vars are actually passed, which volumes are actually attached, which depends_on conditions are actually set.

If after a YAML change config already shows the correct env vars or mounts, but the application still behaves the old way, that is not a contradiction. It means it is time to compare the actual state via docker inspect and recreate the service instead of rewriting the same YAML again.

A very common disguise is when both YAML fragments look “logical,” but together they produce an incompatible setup. For example, the base file defines an export path, while the dev file mounts a different path:

# compose.yaml (fragment)
services:
  app:
    environment:
      # This is the path where the application will try to write files inside the container
      APP_EXPORT_DIR: /app/exports
# compose.dev.yaml (fragment)
services:
  app:
    volumes:
      # Left side is the path on the host, right side is the path inside the container (and here it is easy to make a mistake in target)
      - ./data/exports:/app/export  # note: export, not exports

Each piece separately looks plausible. In the end, the application writes to /app/exports, but the mount is attached to /app/export. On the Java side you will see “the file did not appear” or “Permission denied,” and it is very easy to start digging into the export code. In fact, this is a pure Compose problem, and docker compose config shows it literally at a glance.

Another important detail that often confuses students: .env and environment: are different levels. In Compose, .env primarily participates in substituting values into YAML, and is not “automatic transmission of all variables into the container.” If you expect that “since SPRING_PROFILES_ACTIVE=postgres is in .env, the container will definitely get it,” you may be in for a surprise. You should check it the same way: through docker compose config and, if needed, through docker exec ... printenv.

4. Compose masks in Spring Boot logs

In a multi-container environment, the application becomes a storefront: it is the first to show the error because it is the first to “try to work.” But the class of the error may still be at the Compose level. To avoid guessing, it helps to keep several typical correspondences in mind between “what I see in the application logs” and “what may actually be broken in the Compose model.”

Below is a small cheat-sheet table. Do not treat it as a guide “for all occasions”; it is more of a way to quickly formulate the first hypothesis and choose a cheap check.

What you see in the app logs (Spring Boot) How it feels Common root cause in Compose What to check first
UnknownHostException: postgres or Name or service not known “The application cannot see the DB” Hostname typo, wrong service name, wrong network docker compose config (URL), docker compose ps (is the service present)
Connection refused on postgres:5432 “The database is not accepting connections” The database is not ready yet, no service_healthy, wrong port inside the network docker compose ps (health), postgres logs
Failed to configure a DataSource / no datasource URL “Spring cannot bring up the context” Env vars were not passed, empty substitution from ${...}, wrong profile docker compose config (environment), then docker exec app printenv
Connection error to Redis/RabbitMQ with profiles enabled “The cache/messaging code is broken” You enabled profiles but brought up a partial stack without dependencies What is actually up (docker compose ps) and which profiles are active
AccessDeniedException, Permission denied, “cannot create file” “The export broke” The mount is attached to the wrong place or the target dir permissions are missing docker compose config (volumes + APP_EXPORT_DIR), docker exec app ls -la

Notice the common structure: in almost every case, the first check is not “open the IDE and look for the bug,” but “look at the actual runtime parameters.” In Docker/Compose it is especially dangerous to treat the symptoms “out of habit”: you see a stack trace, and your brain automatically switches to the mode “I am a Java developer, so now I need to fix Java.” But the correct move is to become an “environment developer” for 2 minutes and confirm that you have actually given the application what it expects.

It is also worth mentioning partial startup separately, because it is one of the most plausible sources of “false” errors. You may have brought up only app + postgres, but left SPRING_PROFILES_ACTIVE=postgres,cache. The application will honestly try to connect to Redis and complain. In the logs this looks like “the cache broke.” In reality, the problem is a mismatch: the active profiles demand a dependency that you did not bring up in the current scenario.

Likewise, you can accidentally enable the messaging profile and get RabbitMQ errors, even though at that moment you are debugging not messages at all, but, for example, file export. And again: the error is in the application logs, while the root cause is in the startup model.

5. Logs from multiple services

In a single container everything is simple: one container, one process, one log stream. In a Compose stack this does not work like that, because you have at least two processes talking to each other, and often the root cause is hidden precisely in that dialogue. That is why it is very useful to learn to read logs as a chain of causes and effects, not as a set of independent texts.

In practice it looks like this: you see that app writes “cannot connect to PostgreSQL.” If you read only the app logs, you get a nice stack trace and the feeling that “everything is bad.” But if you read the postgres logs at the same time, you will often see a much more precise answer: the database is still starting, the database rejected the password, the database did not create the user, the database crashed during migration (depending on the scenario). At that moment the application is just a client reflecting the symptom.

The easiest way to assemble the chain is to use the Compose log variant:

# Look at the logs of both the client (app) and the dependency (postgres) at once to see the cause-and-effect chain
docker compose logs app postgres

If you want to watch live, add follow mode:

# Follow mode is useful when you restart services and catch the moment of failure/readiness
docker compose logs -f app postgres

And if there are too many logs (which happens with Spring Boot even on Mondays), then a short tail is useful:

# Take only the last lines to quickly see the current cause without scrolling
docker compose logs --tail=50 app postgres

What matters here is not the number of flags, but the habit: as soon as you have a dependency, you should always have at least two log streams in your head. And when you build a hypothesis, you ask not “why did the application crash,” but “who said first that something was wrong.”

In Compose diagnostics it is sometimes useful to start even not with logs, but with the service state:

docker compose ps

You will get a table showing which containers are Up, which are Exited, and which have the healthy/starting status. If postgres is still starting, while app is already trying to connect and crashing, then this is almost a textbook readiness problem. And again: from the outside it will look like “Spring is broken” — in reality, you are just starting the stack without the dependency being truly ready.

6. An error in the resolved YAML

There is a special class of problems that is psychologically the hardest: when you look at compose.yaml, everything is perfect, and even a colleague beside you says “yes, looks fine.” But the stack still breaks. In such cases, almost always the culprit is that the real startup configuration is not compose.yaml, but the result of combining several files and substitutions. In other words, the problem lives in the resolved YAML.

The most common scenario is an unexpected override in compose.dev.yaml. For example, you wanted to add a volume in dev mode for convenience or open a debug port, but accidentally overrode the wrong section and broke the base mode. This is very similar to a situation in Java where you wanted to “change the config a little for tests,” but accidentally overrode a production setting — only here it happens at the YAML level.

The second common story is a path conflict. For mounts in Compose, the target path inside the container is what matters. If APP_EXPORT_DIR points to one place, and the volume is mounted somewhere else, the behavior becomes “magical” and irritating: the export “works” (you see success in the logs), but the file is “not there” on the host. And you start suspecting CSV, code, access rights, the phase of the moon... although it was enough to compare two paths in docker compose config.

The third story is readiness and depends_on. If you see in the resolved config that you have condition: service_started, that only means “the postgres container started,” but it does not mean “PostgreSQL is ready to accept connections.” The application can quite honestly crash on startup with a connection error, and this will look like an application startup failure. The root cause, however, is that the startup model did not wait for the dependency to become ready. This mask is so common that it is useful to recognize it at a glance.

Here is an example that looks “almost correct,” but causes flaky behavior:

services:
  app:
    depends_on:
      postgres:
        # The container has started, but the service inside it may still not be ready
        condition: service_started

And here is a version that better expresses the real dependency:

services:
  app:
    depends_on:
      postgres:
        # We wait for readiness (healthcheck), not just for the fact that the container started
        condition: service_healthy

The difference is small, but the effect is huge: you stop playing “guess whether the database has woken up in time.”

And finally, the most “invisible” cause: variable substitution produced an empty value. For example, the YAML says:

services:
  app:
    environment:
      SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}

If the variable is not set, the final resolved configuration may contain an empty value, and the application will start in a completely different mode than you expect. And you will get the error “it worked yesterday, but not today,” although in reality only the environment from which Compose took the value changed. docker compose config will show you this “as is,” without any imagination.

7. Mini diagnostic scheme for a Compose mask

It is important not to turn this lecture into a premature “full playbook” (we will assemble it carefully in the next lecture of the day). But you already need a minimal scheme specifically for the Compose mask now, so you do not fall into the typical “IDE-first” reflex.

You can keep this short route in mind: first check the startup model, then the service state, then build the chain from the logs, and only then go inside the container. This sounds too rational, so let us pin it down as a simple flowchart.

flowchart TD
    A["The error surfaced in the application inside Compose"] --> B["Check the resolved model: docker compose config"]
    B --> C["Check the state: docker compose ps"]
    C --> D["Read the log chain: docker compose logs app + dependencies"]
    D --> E{"Has the hypothesis become specific?"}
    E -->|"Yes"| F["Targeted check: inspect/exec only where needed"]
    E -->|"No"| G["Clarify: what was the first symptom and where?"]
    F --> H["One fix"]
    H --> I["Repeat the original check"]

What matters in this scheme is that it protects you from two expensive mistakes. The first mistake is fixing the application without confirming that the environment actually matches the expectation. The second mistake is doing “a hundred checks in a row” without formulating a hypothesis. The Compose mask especially loves chaos: the more chaotically you edit YAML, the less you understand why things got better or worse.

And one more small but practical nuance: when you change YAML, make sure that you really applied the change. Sometimes you need to recreate the service, otherwise you keep looking at the old configuration. This is not “Docker magic,” it is just mechanics: the container has already been created with certain env vars and mounts, and until you recreate it, it will not become “different” by itself. So in diagnostics it is always useful to remember: you are changing not text, you are changing the real runtime.

8. Typical mistakes in diagnosing Compose masks

Mistake #1: read only the application logs and ignore the dependency logs.
This feels natural: you are a Java developer, you have a stack trace in the logs, so you need to read it. But in a Compose stack the stack trace often only reflects a neighbour’s problem. If postgres is still starting or got the wrong password, the application will crash first and loudest, but the root cause lives in another container.

Mistake #2: trust a YAML fragment instead of the resolved configuration.
When you have compose.yaml and compose.dev.yaml, looking at only one file is like diagnosing a bug by one method without reading the calling code. As a result, you fix what was “not used anyway,” while the real override value keeps breaking startup. The docker compose config command saves hours here.

Mistake #3: start a partial startup and forget to match it with the active Spring profiles.
Partial startup is an excellent tool, but it requires discipline. If you brought up only app + postgres, and SPRING_PROFILES_ACTIVE includes cache or messaging, the application will honestly wait for Redis/RabbitMQ and complain. The symptom will look like “the cache/messaging code is broken,” although you simply asked the application to live in a mode for which you did not provide infrastructure.

Mistake #4: confuse service_started with the real readiness of the dependency.
depends_on with service_started often creates the feeling of “sometimes it works, sometimes it doesn’t.” A beginner perceives this as randomness or as “Spring is lagging.” In reality it is timing: the database is up as a container, but not yet ready to accept connections. If you want stability, you must express the ready state in Compose, not just the startup order.

Mistake #5: mount one path and configure the application for another.
This happens especially often with an export directory: APP_EXPORT_DIR has one value, volumes has another target. The application writes files “inside the container,” you do not see them on the host, and you start suspecting the code or the CSV format. But this is a pure mismatch in the Compose model, and it can be checked faster than reading any stack trace.

1
Zadanie
Docker for Spring, poziom 22, lekcja 3
Niedostępne
Wyszukiwanie niezgodnoœci w scalonej konfiguracji Compose
Wyszukiwanie niezgodnoœci w scalonej konfiguracji Compose
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION