Testcontainers Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Testcontainers is a library that starts real services — databases, brokers, mocks, whole Compose stacks — in throwaway containers from test code, and cleans them up afterwards. It turns "integration test against a real PostgreSQL" into a few lines of Java. This page explains how it works on top of Docker and how its API is organised in Testcontainers for Java 2.0.5 (the version Spring Boot 4.1.x manages), on Java 25 with Docker Engine 29.8.x or Docker Desktop 4.91 or later. The Spring Boot integration (@ServiceConnection, containers as beans, the house Compose harness, CI) is on Spring Boot Integration Tests with Testcontainers; @SpringBootTest itself and the basic @Container/@ServiceConnection pattern are on Unit and Integration Testing.

How Testcontainers uses Docker

Testcontainers is a Docker API client (it embeds docker-java). When a test starts a container, the library talks to the Docker daemon exactly as the docker CLI would: pulls the image, creates and starts the container with a random host port for every exposed port, waits until the service is ready, and hands the test the host and mapped port to connect to.

The test JVM talks to the Docker API which starts the test containers and the Ryuk container that removes them when the JVM disconnects

How Testcontainers finds Docker

Testcontainers tries a list of strategies and uses the first daemon that answers:

  1. tc.host in ~/.testcontainers.properties (the setting Testcontainers Desktop writes).

  2. The DOCKER_HOST environment variable (with DOCKER_TLS_VERIFY and DOCKER_CERT_PATH), or the equivalent docker.host properties.

  3. Well-known sockets: /var/run/docker.sock, Docker Desktop’s ~/.docker/run/docker.sock (macOS) or ~/.docker/desktop/docker.sock (Linux), the rootless socket under $XDG_RUNTIME_DIR, and the named pipe on Windows.

Docker CLI contexts are not consulted, so a non-default daemon (Colima, Podman, a remote host) is selected with DOCKER_HOST. Two more variables matter when the Docker host is not localhost:

  • TESTCONTAINERS_HOST_OVERRIDE — the host name or IP where mapped ports are reachable (for example Colima’s VM address).

  • TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE — the socket path inside the VM, used by containers that themselves need the Docker API (Ryuk, containerised Compose).

Every ~/.testcontainers.properties key can also be given as an environment variable: upper-case it, replace dots with underscores and prefix TESTCONTAINERS_ (ryuk.disabled becomes TESTCONTAINERS_RYUK_DISABLED). Derived from General Container runtime requirements and Custom configuration.

Ryuk, the resource reaper

The first time Testcontainers talks to a daemon it starts a small testcontainers/ryuk container, which mounts the Docker socket and keeps a connection open to the test JVM. Every container, network, volume and image Testcontainers creates is labelled with the session ID; when the JVM exits — normally, by a crash, or because you stopped the debugger — the connection drops and Ryuk removes everything with that label. This is why Testcontainers suites do not leak containers even when @AfterAll never runs.

Ryuk needs to mount the Docker socket and, on some platforms, run privileged. Where that is impossible (rootless Podman, some locked-down CI systems) it can be disabled with TESTCONTAINERS_RYUK_DISABLED=true — at the price of cleaning up yourself.

The 2.0 artifact and package model

Testcontainers 2.0 reorganised the Java library. Every page in this section uses the 2.0 names:

1.x (historical) 2.0

Core artifact

org.testcontainers:testcontainers

org.testcontainers:testcontainers (unchanged)

JUnit 5 extension

org.testcontainers:junit-jupiter

org.testcontainers:testcontainers-junit-jupiter

Modules

org.testcontainers:postgresql, :kafka, :mongodb, …​

org.testcontainers:testcontainers-postgresql, testcontainers-kafka, testcontainers-mongodb, …​

Module classes

org.testcontainers.containers.PostgreSQLContainer, …​

org.testcontainers.postgresql.PostgreSQLContainer, org.testcontainers.kafka.KafkaContainer, org.testcontainers.mongodb.MongoDBContainer, org.testcontainers.rabbitmq.RabbitMQContainer, …​

JUnit 4

@Rule / @ClassRule support built into the core GenericContainer

Removed from core; use JUnit 5 (Jupiter)

The old class names still exist in several 2.0 modules as deprecated shims for migration; do not use them in new code. Core classes keep their packages (org.testcontainers.containers.GenericContainer, org.testcontainers.containers.ComposeContainer, org.testcontainers.containers.wait.strategy.Wait, org.testcontainers.utility.DockerImageName). With Spring Boot 4.1 the BOM manages every version:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-postgresql</artifactId>
  <scope>test</scope>
</dependency>

GenericContainer

Any image can be run with GenericContainer:

import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;

Network network = Network.newNetwork();

GenericContainer<?> nginx = new GenericContainer<>(DockerImageName.parse("nginx:1.29"))
        .withExposedPorts(80)                                   // container port; host port is random
        .withEnv("NGINX_ENTRYPOINT_QUIET_LOGS", "1")
        .withCopyFileToContainer(MountableFile.forClasspathResource("static/index.html"),
                "/usr/share/nginx/html/index.html")
        .withNetwork(network)
        .withNetworkAliases("web")                              // other containers on `network` reach it as web:80
        .withLogConsumer(new Slf4jLogConsumer(org.slf4j.LoggerFactory.getLogger("nginx")))
        .waitingFor(Wait.forHttp("/").forStatusCode(200));

nginx.start();
String url = "http://" + nginx.getHost() + ":" + nginx.getMappedPort(80) + "/";
var result = nginx.execInContainer("nginx", "-v");          // run a command inside, get exit code and output
nginx.stop();

Never hard-code localhost or a fixed port: getHost() is not localhost on remote or VM-based daemons, and the host port is random precisely so that parallel builds cannot collide. Derived from Creating a container, Networking and communicating with containers, Files and volumes, Accessing container logs and Executing commands.

Wait strategies

A started container is not a ready service. Testcontainers waits (60 seconds by default) using a strategy:

Strategy Ready when

Wait.forListeningPort() (default)

The first exposed port accepts connections — the weakest signal.

Wait.forHttp("/actuator/health").forStatusCode(200)

An HTTP endpoint answers as expected.

Wait.forLogMessage(".database system is ready to accept connections.", 2)

A log line appears the given number of times.

Wait.forHealthcheck()

The image’s HEALTHCHECK (or Compose healthcheck) reports healthy — the strongest signal when one exists.

Wait.forSuccessfulCommand("pg_isready")

A command inside the container exits with 0.

Modules ship sensible defaults (PostgreSQLContainer waits for the "ready to accept connections" log line), and .withStartupTimeout(Duration.ofMinutes(2)) extends the limit. Derived from Waiting for containers to start or be ready.

The JUnit 5 extension and container lifecycles

import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
class OrderRepositoryIT {

    @Container   // static: started once before all tests of the class, stopped after them
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18.1");

    @Test
    void connects() throws Exception {
        try (var connection = java.sql.DriverManager.getConnection(
                postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
            org.assertj.core.api.Assertions.assertThat(connection.isValid(2)).isTrue();
        }
    }
}
sequenceDiagram participant J as JUnit Jupiter participant E as Testcontainers extension participant D as Docker daemon participant C as postgres container J->>E: beforeAll(OrderRepositoryIT) E->>D: pull if missing, create, start D->>C: start E->>C: wait strategy (log message) C-->>E: ready loop each test method J->>J: run test against getJdbcUrl() end J->>E: afterAll E->>D: stop and remove container Note over D: Ryuk removes anything left if the JVM dies
  • A static @Container field is shared by all tests in the class; an instance field restarts for every test method — isolated but slow.

  • Singleton containers: to share one container across all test classes, start it in a static initializer of a base class (no @Container); Ryuk stops it when the JVM exits. This is the pattern behind the house AbstractIT.

  • Manual lifecycle: call start()/stop() yourself (or use try-with-resources) when a container is needed only for part of a test.

ComposeContainer: an existing compose.yaml

ComposeContainer starts the services of a Compose file and exposes chosen ports through a small ambassador container, so the Compose file does not need to publish anything for the tests:

import java.io.File;
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;

ComposeContainer environment = new ComposeContainer(
        DockerImageName.parse("docker:29.8.1-cli"),                  // runs `docker compose` inside this image
        new File("../compose.yaml"))
    .withCopyFilesInContainer("wiremock")                           // copy only compose.yaml and ./wiremock
    .withExposedService("postgres", 5432, Wait.forHealthcheck())
    .withExposedService("kafka", 9092, Wait.forHealthcheck())
    .withStartupTimeout(java.time.Duration.ofMinutes(3));

environment.start();
String host = environment.getServiceHost("postgres", 5432);
int port = environment.getServicePort("postgres", 5432);

The constructor chooses how Compose runs: the (DockerImageName, File…​) form runs docker compose inside that image (the documented form — no Compose installation needed on the test machine); the older (File…​) constructors use the local docker compose binary. The containerised form copies every file in the directory that holds compose.yaml (typically the repository root, .git and target/ included) into that image’s container unless withCopyFilesInContainer(…​) lists the paths to copy — relative to the Compose file, which is always copied; here wiremock, the directory the house compose.yaml bind-mounts into its wiremock service. The option applies only to the containerised form. The 1.x withLocalCompose(…​) method no longer exists. Service names are the Compose service names (Testcontainers appends the -1 instance suffix itself). Derived from Docker Compose Module. How the house harness uses it is on Spring Boot Integration Tests with Testcontainers.

Modules

Modules wrap an image with a typed API, a correct wait strategy and helpers (getJdbcUrl(), getBootstrapServers()). Those used elsewhere in this repository:

Technology Artifact / class (2.0) Where this repository uses it

PostgreSQL (and MySQL, MariaDB, …​)

testcontainers-postgresql / org.testcontainers.postgresql.PostgreSQLContainer

Hibernate integration testing, Spring Boot testing

Kafka

testcontainers-kafka / org.testcontainers.kafka.KafkaContainer (apache/kafka) or ConfluentKafkaContainer

Spring Boot Testing for Messaging

RabbitMQ

testcontainers-rabbitmq / org.testcontainers.rabbitmq.RabbitMQContainer

Spring Boot Testing for Messaging

MongoDB (and Atlas Local)

testcontainers-mongodb / org.testcontainers.mongodb.MongoDBContainer, MongoDBAtlasLocalContainer

MongoDB Atlas Search

Elasticsearch

testcontainers-elasticsearch / org.testcontainers.elasticsearch.ElasticsearchContainer

Hibernate Search integration testing

Solr

testcontainers-solr / org.testcontainers.solr.SolrContainer

Solr with Spring Boot

Object storage (LocalStack, MinIO, S3Mock, Azurite, fake-gcs-server)

testcontainers-localstack / org.testcontainers.localstack.LocalStackContainer; testcontainers-minio / org.testcontainers.containers.MinIOContainer; others via GenericContainer

File storage and object stores (which explains the current licensing of LocalStack and MinIO)

Keycloak

Community module com.github.dasniko:testcontainers-keycloak, or GenericContainer

OAuth testing and debugging

WireMock

Community module org.wiremock.integrations.testcontainers:wiremock-testcontainers-module, or GenericContainer

Spring Boot Integration Tests with Testcontainers

MockServer

testcontainers-mockserver / org.testcontainers.mockserver.MockServerContainer

 — 

Redis

GenericContainer on redis:8.2 (Spring Boot recognises it by image name) or the community com.redis:testcontainers-redis

 — 

Community modules are released on their own schedule; check that the version you use supports Testcontainers 2.0. Derived from the database modules and the individual module pages under Modules.

Reuse mode (local only)

Reusable containers survive the JVM, so the next run skips start-up entirely. Enable it per machine and per container:

# ~/.testcontainers.properties -- on developer machines only
testcontainers.reuse.enable=true
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18.1").withReuse(true);
static { postgres.start(); }       // manual start; no @Container, which would stop it

Reuse is experimental and meant for local iteration: reused containers are not removed by Ryuk, keep their data between runs, and must never be enabled in CI, where a fresh environment per run is the point. Derived from Reusable Containers.

Image pulls and rate limits

Testcontainers pulls images from Docker Hub by default — the images your tests use plus its own helpers (Ryuk, alpine for startup checks, the Compose image). In CI, authenticate to Docker Hub (Testcontainers reads the same ~/.docker/config.json credentials as the CLI), or redirect every Docker Hub image to a mirror:

# ~/.testcontainers.properties or TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX
hub.image.name.prefix=registry.example.com/dockerhub-mirror/

withImagePullPolicy(PullPolicy.alwaysPull()) forces a fresh pull when a tag is known to move. Derived from Image Registry rate limiting and Image name substitution; the CI side is on Registries and Docker Hub.

Supported environments

Environment Notes

Docker Desktop (macOS, Windows, Linux)

Works out of the box.

Docker Engine on Linux (including GitHub-hosted ubuntu-latest runners)

Works out of the box via /var/run/docker.sock.

Remote Docker host

DOCKER_HOST=tcp://…​ (with TLS) or ssh://; mapped ports are on that host.

Colima

DOCKER_HOST=unix://$HOME/.colima/default/docker.sock, TESTCONTAINERS_HOST_OVERRIDE set to the VM address, TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock.

Podman

DOCKER_HOST pointing at the Podman socket; rootless Podman needs TESTCONTAINERS_RYUK_DISABLED=true.

Rancher Desktop

Point DOCKER_HOST at ~/.rd/docker.sock (moby engine selected).

Testcontainers Desktop / Testcontainers Cloud

A desktop app that manages the runtime and can run containers in Docker’s cloud; named here only.