1. Final readiness baseline
By this point, the problem has already been named: the short depends_on orders container startup, but does not wait until PostgreSQL really begins accepting connections. Now we will put together one neat, readable, and reproducible piece of compose.yaml that turns app + postgres from “starts sometimes” into “starts predictably.” This is not about making things more complex for the sake of complexity: it is about expressing the startup rule once so that Docker Compose, your future team, and you yourself two weeks from now will all understand it.
Readiness baseline in today's context is two connected settings. The first part lives with PostgreSQL and says: “this is how to check that I am truly ready.” The second part lives with the application and says: “I start only after the dependency has passed its health check.” Docker Compose states the problem directly: at startup it does not wait for the service to become “ready”; it only waits for the container to be “running”, and that is why a database needs healthcheck + depends_on.condition.
To make it completely concrete, here is a short “who is responsible for what” diagram:
| Layer | Where it lives | What it guarantees |
|---|---|---|
| Database readiness signal | postgres.healthcheck | “PostgreSQL really is accepting connections” |
| Waiting for readiness | app.depends_on.postgres.condition: service_healthy | “The application does not start until the DB is healthy” |
| Connecting to the database | SPRING_DATASOURCE_URL | “The application goes to the DB by service name and container port” |
2. The final compose.yaml, piece by piece
Now we will do what is usually called a “baseline”: a minimal but professional template. The idea is not to write the shortest YAML on the planet, but to make it read like a scenario. A good compose.yaml is like a clear contract: who starts, what they wait for, and how to tell that everything really works.
Let us start with the application part. We need two things: a long depends_on with service_healthy and Spring Boot configuration for postgres mode. The important thing is that we do not change the image and do not change the business code: only the startup environment changes, as it should in the container world.
Fragment 1 — app: wait for the DB and connect
services:
app: # application service
build: .
depends_on:
postgres:
condition: service_healthy # start only when postgres becomes healthy
environment:
SPRING_PROFILES_ACTIVE: postgres # enable the profile with Postgres settings
# Important: `postgres` is the service name in the Compose network, and `5432` is the container port
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
The key point here is the line condition: service_healthy: Compose really can wait for a dependency's healthcheck to pass before starting the dependent service. And the second point is the connection URL: postgres:5432, that is, service name and container port. If you try to use localhost, you are back in the world of “why did it crash.” Inside a container, localhost means the container itself, not the neighboring database.
In a real project, there will almost certainly also be SPRING_DATASOURCE_USERNAME and SPRING_DATASOURCE_PASSWORD. I am intentionally not spelling them out in this first fragment so that a beginner's eye does not drown in details, but the logic is simple: these values must match POSTGRES_USER / POSTGRES_PASSWORD on the database service.
Fragment 2 — postgres: healthcheck with pg_isready
Now let us add what makes “ready” measurable: a healthcheck for PostgreSQL. In the Docker documentation on startup order, the approach is shown directly: the readiness of a dependency is expressed through healthcheck, and the dependent service waits for service_healthy.
services:
postgres: # database service
image: postgres:17 # Postgres image for the current setup
environment:
POSTGRES_DB: catalog # database name the container will create on first startup
POSTGRES_USER: catalog # database user
POSTGRES_PASSWORD: catalog # database user password
healthcheck:
# Readiness check command: "can it accept connections yet?"
test: ["CMD", "pg_isready", "-U", "catalog", "-d", "catalog"]
pg_isready is a tiny “doctor” that asks PostgreSQL: “are you ready to accept connections, or have you not had your coffee yet?” In the Docker Java guide, this is exactly how they recommend checking the database: healthcheck with pg_isready and waiting for service_healthy on the application side.
Now let us add timing parameters. They do not affect the “correctness” of the idea, but they do affect how well it behaves on different machines.
services:
postgres:
healthcheck:
interval: 5s # how often we check readiness
timeout: 3s # how long we wait for one check response
retries: 10 # how many failed checks in a row are allowed
start_period: 10s # "grace" time for warm-up before strict checks
These four numbers are not “magical”, but they should express the idea: “give the database a little time to start, then check regularly until it becomes healthy.”
Fragment 3 — full baseline
When everything is put together, it looks roughly like this. Yes, this is more than 10 lines, but this is one of those cases where compactness is worse than readability:
services:
app: # application (Spring Boot)
build: .
depends_on:
postgres:
condition: service_healthy # wait until the DB becomes healthy
environment:
SPRING_PROFILES_ACTIVE: postgres
# Connect by the service name in the Compose network, NOT by localhost
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
# These creds must match POSTGRES_USER/POSTGRES_PASSWORD on postgres
SPRING_DATASOURCE_USERNAME: catalog
SPRING_DATASOURCE_PASSWORD: catalog
ports:
- "8080:8080" # expose the application port on the host
postgres: # database
image: postgres:17
environment:
POSTGRES_DB: catalog
POSTGRES_USER: catalog
POSTGRES_PASSWORD: catalog
healthcheck:
# DB readiness: check whether Postgres accepts connections
test: ["CMD", "pg_isready", "-U", "catalog", "-d", "catalog"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
If you are looking at YAML right now and thinking, “why could this not all be one button?”, then congratulations: you have just felt why DevOps engineers sometimes look tired. But the good news is that this is truly a small, clear, and standard baseline.
And yes: compose.yaml is the canonical file name, and it is considered the preferred path in the Compose model.
3. Real startup: events and signs
We often try to judge a system by one sign: “the container started.” In the case of a database, this is especially tricky: the container can be Up, the process is alive, but the DB itself is not yet ready for connections. That is why in the readiness model we deliberately look at the sequence: “postgres started → postgres healthy → app started.” This is not philosophy; it is a way to stop depending on luck and laptop speed.
Here is how this can be represented as a scenario:
sequenceDiagram
participant C as docker compose
participant P as postgres
participant A as "app (Spring Boot)"
C->>P: start container
P-->>P: "initialization (starting up)"
C->>P: "healthcheck: pg_isready (loop)"
P-->>C: "healthy (exit code 0)"
C->>A: "start container (depends_on: service_healthy)"
A-->>P: connect jdbc:postgresql://postgres:5432/catalog
A-->>C: started OK
If the diagram feels too “smart”, you can reduce it to one simple observation: the application should not crash on startup with Connection refused just because the database starts a couple of seconds later.
Good startup logic almost always reads in logs like a human story. Roughly like this:
postgres-1 | database system is ready to accept connections
app-1 | Started CatalogApplication in 4.123 seconds
This is not “decorative beauty”, but the main result of the day: the stack starts reliably because waiting is expressed through a checkable state, not through “let us just wait 20 seconds and hope.”
4. Diagnosing the readiness baseline
The strength of the readiness baseline is that it does not just “work” but is also “observable.” We do not start the stack blindly and guess from coffee grounds. We look at statuses and logs. And we do not need a full observability stack for this; two commands are enough, the ones you have already seen: docker compose ps and docker compose logs.
Here is the canonical minimal set. It is truly worth keeping in mind as “open the hood”:
docker compose ps # view container statuses and healthcheck (healthy/unhealthy)
docker compose logs -f app postgres # read the application and database logs at the same time
In docker compose ps, the column you care about is not only “Up”, but health. When a service has a healthcheck, the status changes and starts carrying meaning. In Docker documentation, this meaning is described literally: service_healthy means the dependency must be “healthy”, and “healthy” is defined by healthcheck.
An important psychological point: if you read only application logs, you will almost certainly start “fixing the wrong thing.” For example, you might change SPRING_DATASOURCE_URL, although the problem was not the URL but the database startup time. When you look at the app and postgres logs together, the cause-and-effect relationship becomes annoyingly obvious. And annoyingly obvious is, in fact, a compliment to infrastructure.
You can even keep a small table of signal interpretation:
| What you see | What it means | What to do mentally |
|---|---|---|
| postgres ... Up (healthy) | The DB has passed the check | The application is allowed to start |
| postgres ... Up without (healthy) | healthcheck is missing or not working | You cannot use service_healthy as a guarantee |
| app ... Exited (1) | The application crashed during startup | Immediately check the app and postgres logs together |
| postgres ... (unhealthy) | healthcheck is failing | The problem is either in the check parameters or in the DB itself |
5. healthcheck parameters
The numbers in healthcheck are not magical. For a local app + postgres setup, this set does exactly what we need: the check runs often enough, one slow attempt does not declare the database broken, and start_period gives it time to survive a cold start peacefully.
If PostgreSQL on your machine really wakes up more slowly, you should adjust these parameters, not add a fixed-delay to the application startup.
Baseline quality criterion
A good sign is that the stack can be brought up several times in a row, and app no longer fails with a random Connection refused at startup. After that, the problem shifts from timing to data lifecycle: now it becomes important not to “guess readiness” but to handle migrations, reset, and recovery of the local database properly.
6. Typical mistakes when configuring the readiness baseline
Mistake #1: the healthcheck was written “for show.”
Sometimes you see test: ["CMD", "true"] or something similar that always succeeds. This is worse than having no healthcheck at all, because it creates an illusion of reliability: Compose will start treating the service as “healthy” instantly, and the application will start and fail just as before. Readiness must measure PostgreSQL's actual ability to accept connections, and pg_isready is exactly such a practical indicator.
Mistake #2: depends_on was left in short form.
The short form depends_on: - postgres sets order, but not readiness. As a result, you think you “made a dependency”, but the race condition remains. You need the long form with condition: service_healthy, because Compose waits for healthcheck to pass only for dependencies marked as service_healthy.
Mistake #3: the application connects to localhost or to the host port.
This is a classic habit after running locally without Docker: “the DB is on localhost.” In the Compose network, localhost inside a container is the application container itself, not the database. Therefore the JDBC URL inside Compose must point to the service name postgres and the container port 5432. If you use the host port, you are effectively trying to bypass the Compose network and reintroduce unnecessary fragility.
Mistake #4: YAML indentation swallowed your condition, and you did not notice.
This is a quiet pain: Compose does not always “shout” in the way you want, and a person can easily miss one space. When condition is not under the right key, Compose simply treats depends_on as a normal list and does not wait for the healthcheck. If Connection refused suddenly appears again at startup, the first thing to check is not the Java code, but the YAML structure.
Mistake #5: overly aggressive timeout and start_period.
If you set start_period: 0s and timeout: 1s, you will get “unhealthy” even on a normal database, simply because it had not had time to start and the check is too nervous. In the Docker startup order example, they directly show that start_period is often made noticeable so that healthcheck is honest with respect to the database startup phase.
GO TO FULL VERSION