How to Run Your Spring Boot App and Database Together With Docker Compose
by Eric Hanson, Backend Developer at Clean Systems Consulting
The startup race you didn't know you were in
Your docker-compose.yml starts a Spring Boot app and a PostgreSQL database. You run docker compose up. The app container starts. Spring Boot initializes and immediately tries to connect to the database. The database is still initializing, hasn't accepted the data directory yet, and returns "connection refused." Spring Boot fails with Connection to localhost:5432 refused. The container exits. Compose reports unhealthy. You restart it manually and this time it works, because the database had time to finish starting up.
This race condition is the most common problem in multi-service Compose setups. The fix requires two things: a health check on the database container, and a depends_on condition on the app container that waits for that health check.
The correct Compose setup
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-myapp}
POSTGRES_USER: ${DB_USER:-appuser}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-appuser} -d ${DB_NAME:-myapp}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-myapp}
SPRING_DATASOURCE_USERNAME: ${DB_USER:-appuser}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
depends_on:
db:
condition: service_healthy
volumes:
pg_data:
Let's break down the non-obvious parts.
The hostname is the service name
Inside the Compose network, services reach each other by service name. Your Spring Boot app reaches the database at hostname db, not localhost and not the container IP.
# This is wrong — localhost doesn't resolve to the database inside the container
spring.datasource.url=jdbc:postgresql://localhost:5432/myapp
# This is correct — 'db' is the Compose service name
spring.datasource.url=jdbc:postgresql://db:5432/myapp
The environment variable SPRING_DATASOURCE_URL in the Compose file maps to spring.datasource.url in Spring Boot's configuration via its relaxed binding — uppercase with underscores maps to lowercase with dots. This works for any Spring Boot property:
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
LOGGING_LEVEL_COM_YOURCOMPANY: debug
SERVER_PORT: 8080
This approach is preferable to mounting an application.properties because the configuration is visible in the Compose file without opening a separate file.
pg_isready: why it's the right health check tool
pg_isready is bundled with the official Postgres Docker image. It checks whether the PostgreSQL server is ready to accept connections without requiring credentials for the check. Importantly:
- It returns exit code 0 when the server is accepting connections
- It returns non-zero codes when the server is not yet ready (starting up) or when it's rejecting connections
- It checks the actual connection acceptance, not just process presence
# The health check command
pg_isready -U appuser -d myapp
Without -U and -d, it uses defaults that may or may not match your configuration. Be explicit.
An alternative using psql directly:
test: ["CMD-SHELL", "psql -U ${DB_USER} -d ${DB_NAME} -c 'SELECT 1' > /dev/null 2>&1"]
This actually queries the database rather than just checking the connection, which is a more thorough check but requires valid credentials. Use pg_isready for the health check (it's faster) and let your application's connection pool handle actual query verification.
Flyway or Liquibase with Compose
If you use Flyway or Liquibase for migrations, you need to decide when migrations run. Two common approaches:
Run migrations on application startup (most common for Compose):
Spring Boot's Flyway autoconfiguration runs migrations when the application starts. With depends_on: condition: service_healthy, the database is ready when Spring Boot starts, so Flyway runs against a healthy database.
environment:
SPRING_FLYWAY_ENABLED: 'true'
SPRING_FLYWAY_BASELINE_ON_MIGRATE: 'true'
This is the simplest setup for local development. The tradeoff: if your application is deployed as multiple instances, they'll race to run migrations on startup. Fine for local Compose; add a separate migration step for production Kubernetes deployments.
Run migrations as a separate Compose service (better for staging):
services:
migrate:
image: flyway/flyway:10-alpine
command: -url=jdbc:postgresql://db:5432/${DB_NAME} -user=${DB_USER} -password=${DB_PASSWORD} migrate
volumes:
- ./src/main/resources/db/migration:/flyway/sql
depends_on:
db:
condition: service_healthy
app:
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
condition: service_completed_successfully (available since Compose v2.1) makes the app wait for the migrate service to exit with code 0. Migrations run exactly once before the app starts, regardless of how many app replicas exist.
Handling Spring Boot's startup time
Spring Boot applications, especially with Hibernate schema validation or a lot of autoconfiguration, can take 30–60 seconds to start. Configure the app's health check to account for this:
app:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
This requires Spring Boot Actuator on the classpath:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
The /actuator/health endpoint returns 200 when the application is healthy, including when database connectivity checks pass. It returns 503 when any health indicator is down, which means if the database connection pool fails, the health check fails and Compose knows the app isn't ready.
The .env file for this setup
# .env — gitignored, copy from .env.example
DB_NAME=myapp
DB_USER=appuser
DB_PASSWORD=changeme_in_dev
.env.example — committed, documents required variables:
DB_NAME=myapp
DB_USER=appuser
DB_PASSWORD= # required — set this
Running it
# Start everything
docker compose up
# Start in background
docker compose up -d
# View app logs
docker compose logs -f app
# Connect to the database from the host
psql postgresql://appuser:changeme_in_dev@localhost:5432/myapp
# Run a one-off command in the app container
docker compose exec app java -jar app.jar --spring.batch.job.enabled=true
The database is exposed to the host via port mapping in the Compose file (add ports: - "5432:5432" to the db service for local access). In a production Compose file, remove this mapping — the database should only be reachable by services within the Compose network.