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.

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

How to Price Your Services as a Remote Contractor

“How much should I charge?” It’s the one question every contractor asks — and the one most people guess wrong.

Read more

The Rate Conversation Most Contractors Handle Badly

The moment a client asks "what do you charge?" is a hinge point in every contractor engagement. Most contractors fumble it in one of three predictable ways.

Read more

Why “Simple Features” Are Often Not Simple

“It’s just a small feature” is one of the most expensive sentences in software. What looks simple on the surface often hides layers of complexity underneath.

Read more

Forced In-Person Work: When Contractors Are Treated Unfairly

“We require all contractors to be onsite five days a week.” That sentence often signals a deeper misunderstanding of what contracting actually is.

Read more