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 viamvn dependency:treeand the realdocker/docker composeCLI 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.
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.yamlgets connection details for all three the same way, with no extra wiring per service. - No custom readiness tuning.
spring.docker.compose.readiness.tcp.connect-timeoutand friends, and theorg.springframework.boot.readiness-check.tcp.disablelabel, 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
| Need | What to use |
|---|---|
| A real database/broker while running the app locally, no manual container management | spring-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 recognised | org.springframework.boot.service-connection label |
| A compose service the app shouldn’t get connection details for | org.springframework.boot.ignore label |
| Starting/stopping containers inside a test’s own lifecycle instead | Testcontainers + @ServiceConnection (this series’ usual tool) |
Further reading
- Spring Boot reference docs: Development-time Services — Spring Boot
- Docker Compose CLI reference: Docker Compose documentation
No Comments yet!