How to Run Your Spring Boot App and Database Together With Docker Compose

by Arif Ikhsanudin, Backend Developer

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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When Asynchronous Developers Are the Right Choice for Your Team

Not every team needs daily meetings and instant replies. Sometimes, the best work happens when people aren’t online at the same time.

Read more

Spring Boot API Security Hardening — Headers, Input Validation, and the Vulnerabilities That Slip Through

Authentication and authorization are necessary but not sufficient for API security. Mass assignment, excessive data exposure, injection vulnerabilities, and missing security headers are the gaps that survive code review and appear in penetration tests.

Read more

Event-Driven vs Request-Driven Architecture — Which One to Pick and When

Event-driven architecture solves temporal decoupling and fan-out elegantly, but it trades synchronous clarity for eventual consistency — a trade-off that only makes sense in specific structural contexts.

Read more

Disagreeing With a Technical Decision Without Burning Bridges

Technical disagreements are inevitable in engineering teams. How they are handled determines whether the team's technical quality improves over time or whether decisions get made by whoever argues longest.

Read more