Testcontainers in CI/CD — GitHub Actions, Docker, and Production Patterns
Testcontainers tests that run perfectly on your developer laptop can fail in CI for environmental reasons — Docker daemon availability, image pull timeouts, port conflicts, and resource limits. This final article covers production-ready CI/CD configuration for Testcontainers in GitHub Actions, GitLab CI, and Jenkins, plus Spring Boot 3.1’s development-time container support.
What You’ll Learn
- GitHub Actions configuration for Testcontainers (zero extra setup on
ubuntu-latest) - GitLab CI configuration with Docker-in-Docker
- Jenkins configuration with Docker socket mounting
TestApplication— running your app locally with containers instead of a real database./mvnw spring-boot:test-runfor dev-time container startup- Container version pinning policies for teams
- Testcontainers Cloud for high-scale CI
GitHub Actions
GitHub Actions runners (ubuntu-latest) have Docker pre-installed and running. Testcontainers works out of the box with no extra configuration.
# .github/workflows/test.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run tests
run: ./mvnw verify
env:
# Testcontainers reads this to avoid pulling images in parallel on slow networks
DOCKER_BUILDKIT: 1
That is it. No Docker daemon setup, no special Testcontainers configuration, no secrets required. GitHub’s ubuntu-latest runner has Docker running on /var/run/docker.sock.
Caching Docker Images in GitHub Actions
Pulling postgres:16-alpine on every run takes 5–15 seconds. Cache Docker images between runs:
- name: Cache Docker images
uses: ScribeMD/docker-cache@0.5.0
with:
key: docker-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
Or use GitHub Actions’ built-in layer caching with Docker Buildx. For Testcontainers specifically, the Docker image layer cache on the runner’s local filesystem persists between runs of the same runner. GitHub’s hosted runners share a clean environment per run, so image pulling is unavoidable without an explicit caching step.
Splitting Unit Tests and Integration Tests in CI
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
- name: Unit tests
run: ./mvnw test -Dexclude="**/*IT.java,**/*SystemTest.java"
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests # run integration tests only if unit tests pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin', cache: 'maven' }
- name: Integration tests
run: ./mvnw verify -DskipTests=false -Dtest="**/*IT.java"
GitLab CI with Docker-in-Docker
GitLab CI’s shared runners do not have Docker available by default. Use Docker-in-Docker (DinD):
# .gitlab-ci.yml
image: maven:3.9-eclipse-temurin-21
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2"
services:
- docker:24-dind
stages:
- test
test:
stage: test
cache:
paths:
- .m2/
script:
- ./mvnw verify
artifacts:
when: always
reports:
junit:
- target/surefire-reports/*.xml
- target/failsafe-reports/*.xml
The DOCKER_HOST: tcp://docker:2375 environment variable tells Testcontainers to connect to the DinD service at tcp://docker:2375 rather than the local socket.
GitLab with Docker Socket Mounting (Preferred)
DinD is slower than mounting the Docker socket directly. If your GitLab runners are configured to allow socket mounting:
test:
stage: test
variables:
DOCKER_HOST: unix:///var/run/docker.sock
script:
- ./mvnw verify
tags:
- docker-socket # GitLab runner tag that allows socket mounting
Jenkins with Docker Socket Mounting
For Jenkins, mount the Docker socket into the agent:
// Jenkinsfile
pipeline {
agent {
docker {
image 'maven:3.9-eclipse-temurin-21'
args '-v /var/run/docker.sock:/var/run/docker.sock -v /tmp:/tmp'
}
}
environment {
DOCKER_HOST = 'unix:///var/run/docker.sock'
}
stages {
stage('Test') {
steps {
sh './mvnw verify'
}
post {
always {
junit 'target/surefire-reports/*.xml'
junit 'target/failsafe-reports/*.xml'
}
}
}
}
}
The Jenkins agent runs in Docker with the host’s Docker socket mounted. Testcontainers connects to the host Docker daemon and starts containers alongside the Jenkins agent container.
Development-Time Containers with TestApplication
Spring Boot 3.1 introduced a pattern for running your application locally using Testcontainers instead of a locally installed database.
Create a TestApplication class in src/test/java:
// src/test/java/com/devopsmonk/orders/TestApplication.java
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication
.from(OrderApplication::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
// src/test/java/com/devopsmonk/orders/TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
@RestartScope
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
@RestartScope
KafkaContainer kafkaContainer() {
return new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1")
);
}
}
Run the application locally with containers instead of a real PostgreSQL:
# Maven
./mvnw spring-boot:test-run
# Gradle
./gradlew bootTestRun
This starts your full Spring Boot application with containers providing all infrastructure. No local PostgreSQL installation required. Ideal for onboarding new team members and consistent local development.
@RestartScope ensures containers are not restarted when Spring DevTools triggers a hot reload — the containers keep running while the application context is refreshed.
Container Image Version Policy
Unpinned container versions cause test failures when new image versions are released. Establish a team-wide policy:
Rule 1: Always Pin Major.Minor Versions
// Bad
new PostgreSQLContainer<>("postgres:latest")
new PostgreSQLContainer<>("postgres:16") // minor version can change
// Good
new PostgreSQLContainer<>("postgres:16.2-alpine") // exact version pinned
Rule 2: Centralize Version Constants
// src/test/java/com/devopsmonk/orders/TestImageVersions.java
public final class TestImageVersions {
private TestImageVersions() {}
public static final String POSTGRES = "postgres:16.2-alpine";
public static final String KAFKA = "confluentinc/cp-kafka:7.6.1";
public static final String REDIS = "redis:7.2-alpine";
public static final String MONGODB = "mongo:7.0.5";
public static final String RABBITMQ = "rabbitmq:3.12-management-alpine";
public static final String WIREMOCK = "wiremock/wiremock:3.3.1";
public static final String LOCALSTACK = "localstack/localstack:3.4.0";
public static final String KEYCLOAK = "quay.io/keycloak/keycloak:24.0.1";
}
// Use in base class
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(TestImageVersions.POSTGRES);
When you upgrade PostgreSQL, change one constant. All tests automatically use the new version.
Rule 3: Version Upgrade Process
- Update the version constant
- Run the full test suite
- Fix any failures caused by version differences
- Commit with the change noted in the PR description
CI Performance Checklist
Before going to production with Testcontainers in CI, verify:
- Docker is available in CI environment
- Container images are cached between runs (where possible)
- Integration tests use the singleton base class pattern (not per-class containers)
- Container startup timeouts are set generously (90s minimum for complex services)
-
testcontainers.reuse.enable=trueis NOT set in CI environment - Unit tests run in a separate, faster Maven phase
- Test results are published as CI artifacts for failure diagnosis
Testcontainers Cloud
Testcontainers Cloud is a service by Testcontainers that offloads container orchestration to cloud-hosted environments. Instead of running Docker on your CI runner, containers run in an isolated Testcontainers Cloud environment and your tests connect to them remotely.
Benefits:
- Faster container startup (pre-pulled images)
- Better resource isolation
- Works in CI environments where Docker is not available
- Dashboard for test run visualization
Setup:
<dependency>
<groupId>cloud.testcontainers</groupId>
<artifactId>testcontainers-cloud-agent</artifactId>
<version>1.x.x</version>
<scope>test</scope>
</dependency>
With the agent on the classpath and TC_CLOUD_TOKEN set in CI, containers run in Testcontainers Cloud automatically. No other code changes.
Complete Production-Ready Base Class
Combining all the patterns from this series:
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final KafkaContainer KAFKA;
static {
POSTGRES = new PostgreSQLContainer<>(TestImageVersions.POSTGRES)
.withDatabaseName("orders_test")
.withReuse(true);
KAFKA = new KafkaContainer(
DockerImageName.parse(TestImageVersions.KAFKA)
).withReuse(true);
Startables.deepStart(POSTGRES, KAFKA).join();
}
@DynamicPropertySource
static void configureInfrastructure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
}
This base class:
- Starts containers once per JVM (singleton pattern)
- Enables reuse for local development
- Starts containers in parallel
- Uses pinned versions from
TestImageVersions - Uses
@DynamicPropertySourcefor compatibility with all Spring Boot versions
Summary
Testcontainers in CI requires only that Docker is available. GitHub Actions ubuntu-latest runners have Docker pre-installed — no extra configuration needed. GitLab CI uses Docker-in-Docker or socket mounting. Jenkins uses socket mounting. For local development, TestApplication with @RestartScope provides a zero-configuration development environment backed by containers.
Series Complete
You have completed the Testcontainers tutorial series. Here is a recap of what was covered:
Foundations: What Testcontainers is, setup, JUnit 5 lifecycle, wait strategies
Database Testing: PostgreSQL, MySQL, MongoDB, Flyway/Liquibase migrations
Messaging: Kafka, RabbitMQ
Infrastructure: Redis, WireMock, LocalStack, Keycloak
Strategy: Testing pyramid, Spring Boot slices
Performance: Singleton containers, container reuse, parallel execution
Production: CI/CD configuration, dev-time containers, version policies
Every article has working, runnable examples against the same e-commerce order management domain. The complete code is available at github.com/devopsmonk/testcontainers-tutorial.
Start from the beginning: What Is Testcontainers and Why You Need It