Skip to main content

Spring Boot Docker Compose Support: Real Containers for Local Development

Testcontainers (used throughout earlier posts in this series) is for tests: start a container, run assertions, tear it down, all inside the test JVM’s lifecycle. Spring Boot’s Docker Compose support solves a neighbouring problem — local development. Run the app normally, with a compose.yaml next to it, and Spring Boot starts the declared containers itself, wires up a real connection to them, and stops them again when the app exits. No spring.datasource.* property anywhere, and no manual docker compose up either.

This post proves that end to end: a real docker compose up actually ran, a real Postgres container came up on a real (non-default) host port, a repository backed by a plain JdbcTemplate read and wrote through it, and — checked after the JVM exited, not assumed — the container actually stopped again.

Versions used in this post. Spring Boot 4.1.1 · Spring Framework 7.0.9 · PostgreSQL JDBC driver 42.7.13 · postgres:17-alpine · Docker 29.8.2 · Docker Compose v5.5.1 · JDK 25 (Temurin). Confirmed via mvn dependency:tree and the real docker/docker compose CLI versions on the machine that ran this post’s tests.

This demo is small enough that it doesn’t warrant its own repository module — every file is linked for download right where it’s discussed below.

The dependency and the compose file

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <optional>true</optional>
</dependency>

Download: pom.xml. optional=true keeps this module off a repackaged jar’s runtime classpath by default — it’s a development-time convenience, not something a deployed artifact should carry. The Maven plugin’s excludeDockerCompose has to be explicitly set to false for the plugin to include it anyway if you do want it in a jar; this demo sets that too, purely so mvn spring-boot:run behaves the same as the tests below.

services:
  postgres:
    image: 'postgres:17-alpine'
    environment:
      - 'POSTGRES_DB=appdb'
      - 'POSTGRES_USER=appuser'
      - 'POSTGRES_PASSWORD=secret'
    ports:
      - '5432'

Download: compose.yaml. Note the port mapping is just '5432', not '5432:5432' — no host port is pinned, so Docker Compose publishes the container’s 5432 to a random free port on the host. That’s deliberate: it’s what makes the next section’s assertion mean something.

What happens when the app actually starts

No spring.datasource.* property exists anywhere in this module’s src/main. The repository is ordinary JdbcTemplate code:

@Repository
public class WidgetRepository {

    private final JdbcTemplate jdbcTemplate;

    public WidgetRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void createTable() {
        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS widget (id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL)");
    }

    public int insert(String name) {
        jdbcTemplate.update("INSERT INTO widget (name) VALUES (?)", name);
        return jdbcTemplate.queryForObject("SELECT currval(pg_get_serial_sequence('widget','id'))", Integer.class);
    }

    // nameOf(), count() - see the full file
}

Download: WidgetRepository.java · application entry point: DockerComposeDemoApplication.java. Running this with no database configured anywhere would normally fail fast on a missing DataSource. With spring-boot-docker-compose on the classpath, Spring Boot instead finds compose.yaml next to the app, runs docker compose up for it, and registers a JdbcConnectionDetails bean built from the real container it just started — the same kind of bean a hand-written DataSourceProperties configuration would otherwise supply. The real Maven test-run log shows exactly this sequence happening, not simulated:

Using Docker Compose file /home/claude/work/inline-posts/32-docker-compose/compose.yaml
 Network 32-docker-compose_default  Creating
 Network 32-docker-compose_default  Created
 Container 32-docker-compose-postgres-1  Creating
 Container 32-docker-compose-postgres-1  Created
 Container 32-docker-compose-postgres-1  Starting
 Container 32-docker-compose-postgres-1  Started
 Container 32-docker-compose-postgres-1  Waiting
 Container 32-docker-compose-postgres-1  Healthy

That log is Spring Boot’s own DockerComposeLifecycleManager and DockerCli components driving the real docker compose CLI — the exact log category names (o.s.b.d.c.l.DockerComposeLifecycleManager, o.s.boot.docker.compose.core.DockerCli) appeared in the terminal this test actually ran in.

Proving the port isn’t 5432

@SpringBootTest(classes = DockerComposeDemoApplication.class)
class DockerComposeConnectionTest {

    @Autowired
    JdbcConnectionDetails connectionDetails;

    @Autowired
    WidgetRepository widgetRepository;

    @Test
    void jdbcConnectionDetailsPointsAtTheRealContainerPortNotTheDefault() {
        String jdbcUrl = connectionDetails.getJdbcUrl();

        assertThat(jdbcUrl).startsWith("jdbc:postgresql://");
        assertThat(jdbcUrl).doesNotContain(":5432/");
        assertThat(connectionDetails.getUsername()).isEqualTo("appuser");
    }

    // widgetRepositoryActuallyReadsAndWritesThroughTheComposeStartedContainer() below
}

Download: DockerComposeConnectionTest.java. org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails is the real interface — getUsername(), getPassword(), getJdbcUrl(), confirmed with javap -p against the real spring-boot-jdbc-4.1.1.jar. The URL this test actually got back:

JdbcConnectionDetails.getJdbcUrl() = jdbc:postgresql://127.0.0.1:32787/appdb
compose.yaml published container port 5432 to this host port - not the default
username = appuser (from POSTGRES_USER in compose.yaml, picked up automatically)

Transcript: 32-connection-details.txt. Port 32787 is whatever Docker happened to hand out this run — the assertion only checks it’s not 5432, because the whole point of this feature is that the application never needs to know or care which port that turned out to be.

And the repository really does read and write through that container, not a mock of it:

rows before insert: 0
INSERT ... RETURNING id -> id=1
SELECT name FROM widget WHERE id=1 -> "left-handed widget"
rows after insert: 1 (delta 1)

Transcript: 32-widget-roundtrip.txt.

Going deeper: why this needed a property to even run under @SpringBootTest

Docker Compose support is disabled by default while running tests — the reasoning being that most test suites use Testcontainers (via @ServiceConnection) for exactly this job, and starting a second, unrelated set of containers on every test run would be wasteful or surprising. This demo’s src/test/resources/application.properties turns it back on for this module specifically:

spring.docker.compose.skip.in-tests=false

That’s the one property, and this post’s tests wouldn’t have started any container at all without it — worth knowing if a copy-pasted @SpringBootTest from this demo mysteriously fails to find a JdbcConnectionDetails bean elsewhere.

Confirming the stop half, not just the start half

spring.docker.compose.lifecycle-management defaults to start-and-stop: start the containers on launch, stop them when the JVM exits. The “start” half is obvious from the log above; the “stop” half only shows up after the process is gone, so it can’t be asserted on from inside the same test. It was instead checked with a plain docker ps, run immediately after the Maven process that ran these tests exited, with no manual command in between:

NAMES                          STATUS
32-docker-compose-postgres-1   Exited (0) 6 seconds ago

Transcript: 32-lifecycle-stop.txt. Exited (0) is a clean stop, not a crash — Spring Boot ran the Compose-equivalent of docker compose stop (the default stop.command) as the JVM was shutting down, with no leftover container or network from this run left behind.

Going deeper: image-name matching, custom images, and opting a container out

The JdbcConnectionDetails bean in this post exists because Spring Boot recognises the postgres image name out of the box — the same mechanism also recognises mysql, mongo, redis, rabbitmq, cassandra, elasticsearch, neo4j, and several others, each mapped to its own connection-details type. Two labels extend that matching without writing any Java:

services:
  redis:
    image: 'mycompany/mycustomredis:7.0'
    labels:
      org.springframework.boot.service-connection: redis

  scratch-db:
    image: 'postgres:17-alpine'
    labels:
      org.springframework.boot.ignore: true

The first tells Spring Boot to treat a differently-named image as if it were the real thing for connection-detection purposes. The second excludes a service entirely — useful for a container in the same compose.yaml that the application itself has no business connecting to.

Two different jobs, easy to conflate Testcontainers (@ServiceConnection) for tests, inside the test JVM container starts with the test context container stops with the test context disabled in prod code entirely – test-scope only spring-boot-docker-compose for local development, running the app for real docker compose up on application launch docker compose stop on JVM shutdown off by default in tests – spring.docker.compose.skip.in-tests=false opts in

What this post didn’t cover

  • No multi-service compose file. This demo declares one Postgres service; a real project with Postgres, Redis, and RabbitMQ in the same compose.yaml gets connection details for all three the same way, with no extra wiring per service.
  • No custom readiness tuning. spring.docker.compose.readiness.tcp.connect-timeout and friends, and the org.springframework.boot.readiness-check.tcp.disable label, are documented but this demo’s container was ready well within the defaults.
  • No SSL. Client-side SSL bundles for Cassandra, Elasticsearch, MongoDB, RabbitMQ and Redis are supported via container labels, not exercised here.

When this is the right tool

NeedWhat to use
A real database/broker while running the app locally, no manual container managementspring-boot-docker-compose + compose.yaml
Fixed-image service auto-detected (postgres, mysql, redis, mongo, rabbitmq, …)Nothing extra – image name matching is automatic
Custom or renamed image that should still be recognisedorg.springframework.boot.service-connection label
A compose service the app shouldn’t get connection details fororg.springframework.boot.ignore label
Starting/stopping containers inside a test’s own lifecycle insteadTestcontainers + @ServiceConnection (this series’ usual tool)

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.