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-run for 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

  1. Update the version constant
  2. Run the full test suite
  3. Fix any failures caused by version differences
  4. 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=true is 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 @DynamicPropertySource for 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