1. Overview

In this lesson, we’ll run our actual application locally against throwaway containers, so a freshly cloned project starts with a single command instead of a hand-installed set of backing services. We’ll begin by starting the app the manual way to understand the problem, then declare the containers our app needs, launch the app wired to them, and confirm it genuinely talks to them.

The relevant module we need to import when starting this lesson is: local-dev-start.

If we want to reference the fully implemented lesson, we can import: local-dev-end.

Running the app in this lesson requires a Docker engine installed and running, since Testcontainers needs it to start the containers.

2. Running the App Locally

Let’s look at what it takes to run our application locally today. Our app talks to three backing services: a Postgres database, a Kafka broker, and a Redis cache. Before the app will even boot, each of those has to be installed and running on our machine.

The reason is noticeable in our configuration. Let’s open application.properties in src/main/resources:

spring.datasource.url=jdbc:postgresql://localhost:5432/ltcdb
spring.datasource.username=ltc
spring.datasource.password=ltc

spring.kafka.bootstrap-servers=localhost:9092

spring.data.redis.host=localhost
spring.data.redis.port=6379

Every connection points at localhost. That’s an assumption, not a convenience: we’re telling Spring Boot that a Postgres instance is already listening on port 5432, that a Kafka broker is up on 9092, and that Redis is answering on 6379. Nothing here starts those services for us.

This pushes a real cost onto every developer who clones the project. We have to provision and start three services by hand before the first run. The versions we install may be different from what the project expects, and onboarding a teammate means writing a setup document rather than handing them a clone-and-run repo.

The usual halfway fix is a hand-maintained compose.yaml that we bring up and tear down ourselves with Docker Compose. It centralizes the services, but we still own that file, keep its versions in sync, and remember to run it. In the sections that follow, we’ll let the application start its own dependencies instead.

3. Testcontainers at Development Time

So what does Spring Boot’s support for Testcontainers at development time actually give us? We clone the repo, run one command, and the whole backing stack comes up in throwaway containers. No local Postgres, no local Kafka, no local Redis: the containers exist only while the app runs, and they disappear when we stop it.

The wiring here is the same @ServiceConnection configuration we saw in a previous lesson, now pointed at running the app rather than at our tests. Each container exposes its connection details automatically, and Spring Boot consumes those details directly. Because the containers supply their own coordinates, the localhost properties are overridden during this development-time run.

There’s one detail worth clarifying before we write any code. In this setup, Testcontainers is available on the test classpath, so we place the development-time configuration under src/test. That sounds contradictory at first: how does a src/test container end up serving a normal application run? The bridge that makes it work is the launcher we’ll build in a later section. For now, the mental model is enough: declare the containers in test scope, then start the production app on top of them.

4. Declaring Containers as Beans

The app needs the containers declared somewhere a run can pick them up.

We’ll add a new class named TestcontainersConfiguration under src/test/java, in the com.baeldung.ltc package:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgresContainer() {
        return new PostgreSQLContainer(DockerImageName.parse("postgres:17"))
          .withInitScript("init-script.sql");
    }
}

Let’s analyze this. We annotate the class with @TestConfiguration(proxyBeanMethods = false) so Spring registers our container as a bean without proxying the method on every call. This is appropriate here because our @Bean methods never call one another, so Spring doesn’t need to proxy the class to guarantee singleton semantics on inter-method calls.

The postgresContainer() method returns a PostgreSQLContainer and is marked @Bean, so the container’s lifecycle is now managed by the Spring context: it starts when the context starts and stops when the context closes.

This is a different declaration shape from the one in the previous lesson. There, we declared containers as static @Container fields inside a test class. Here, they’re @Bean methods in a configuration class, which is exactly the form a running application can import.

The @ServiceConnection annotation does the wiring just as it did before. Spring Boot detects the PostgreSQLContainer type, reads its host and mapped port, and feeds those to the datasource auto-configuration. We don’t need to configure the datasource URL, username, or password manually, since Spring Boot derives them from the container.

If a project already has field-style container declarations it wants to keep, @ImportTestcontainers can pull those existing fields into a configuration instead of rewriting them as @Bean methods. We won’t use it here, since starting fresh with @Bean methods reads more clearly.

5. Launching the App

We have the containers declared, but nothing yet starts the real application on top of them. That’s the job of the launcher.

Let’s add a class named TestLtcApp under src/test/java, in the same com.baeldung.ltc package:

public class TestLtcApp {

    public static void main(String[] args) {
        SpringApplication.from(LtcApp::main)
          .with(TestcontainersConfiguration.class)
          .run(args);
    }
}

The from(LtcApp::main) call reuses our production application’s own bootstrap, so we don’t duplicate any startup logic. The with(TestcontainersConfiguration.class) call registers our configuration as an extra source on top of that bootstrap, which is what pulls the container beans into the context: a @TestConfiguration is not picked up automatically on a normal run, so naming it here is what makes those beans take effect. And run(args) actually starts the application.

Because this main() lives in src/test, the whole thing runs on the test classpath, which is exactly where Testcontainers and @ServiceConnection are available.

Nothing in src/main changed. Our production LtcApp is untouched, and the development-time wiring lives entirely in test scope. That separation matters: we get throwaway containers locally without leaking any test dependency into the application we ship.

To launch it, we run the Spring Boot test goal from the project directory:

mvn spring-boot:test-run

This goal runs our test-side main(), which means it boots LtcApp with the container beans attached. Spring Boot picks that test-side main() because the test-run goal looks for a main class in the test classes first.

We can also run the TestLtcApp main() directly from our IDE, which gives us the same result with breakpoints available.

6. Verifying That the App Talks to the Container

The app starting up isn’t proof on its own that it reached the development-time Postgres. It could, in principle, have connected to a localhost database without complaining. The clearest confirmation is in the startup logs, where Testcontainers reports the container it created and the exact URL the app connected to:

tc.postgres:17 : Creating container for image: postgres:17
tc.postgres:17 : Container is started (JDBC URL: jdbc:postgresql://localhost:55970/test)

The host port is one Docker assigns at random, so it’s different on every run, and the database name is test, not the localhost:5432/ltcdb from our application.properties. That mismatch is the proof: the app is wired to the throwaway container, not to any database we started by hand.

To also watch data round-trip through the running app, we can exercise a real endpoint. With the app running via spring-boot:test-run, let’s create a campaign by posting to the controller:

curl -X POST http://localhost:8080/campaigns \
  -H "Content-Type: application/json" \
  -d '{"code":"C-100","name":"Launch","description":"Spring launch campaign"}'

The controller responds with 201 Created and echoes the saved campaign, now carrying a generated id:

{"id":4,"code":"C-100","name":"Launch","description":"Spring launch campaign","tasks":[]}

Now let’s read it back using that id:

curl http://localhost:8080/campaigns/4

We get the same campaign in response, which means it was persisted and retrieved successfully:

{"id":4,"code":"C-100","name":"Launch","description":"Spring launch campaign","tasks":[]}

The round-trip confirms the data we created persisted to that container and came back intact. No hand-installed database was involved at any point.

7. Starting the Whole Stack

Our app needs all three services, not just Postgres. The good news is that adding the rest is similar.

Let’s add Kafka and Redis to TestcontainersConfiguration:

@Bean
@ServiceConnection
KafkaContainer kafkaContainer() {
    return new KafkaContainer(DockerImageName.parse("apache/kafka"));
}

@Bean
@ServiceConnection(name = "redis")
GenericContainer<?> redisContainer() {
    return new GenericContainer<>(DockerImageName.parse("redis:7"))
      .withExposedPorts(6379);
}

Kafka works the same way Postgres did: it has a dedicated KafkaContainer type, so @ServiceConnection detects it with no extra hint. Redis is the one exception we saw in a previous lesson. Since it’s a plain GenericContainer with no specialized type to recognize, we give @ServiceConnection the name = “redis” hint so Spring Boot knows which service the container represents.

With all three beans declared, a single mvn spring-boot:test-run now brings up the entire local stack. Our existing integration tests are unaffected, since they keep starting their own containers.

8. Conclusion

In this lesson, we turned a project that demanded a hand-installed set of backing services into one we can clone and run with a single command. A small configuration class of container beans, paired with a test-side launcher, is all it took to give the running application throwaway Postgres, Kafka, and Redis instances on demand.

One refinement is worth knowing for the day-to-day loop: annotating a container with @RestartScope keeps it alive across a Spring DevTools restart, so our local database isn’t torn down and recreated every time we change a line of code.