Building reliable CI pipelines for Maven projects
We've all been there: a developer pushes code that passes locally but breaks in production. GitHub Actions CI for Maven projects: a practical guide helps us prevent exactly that. By automating builds, tests, and deployments on every push, we catch issues early and maintain confidence in our codebase. Let's walk through a production-ready workflow that handles testing with Testcontainers, image publishing, and report generation.
Setting up actions/setup-java with Maven cache
The foundation of any Maven CI pipeline is a reliable Java environment. GitHub's setup-java action does the heavy lifting, but we need to configure it thoughtfully to keep builds fast.
Start with a basic workflow that caches Maven dependencies. Here's what we typically use:
name: Maven Build
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn clean packageThe cache: maven parameter is critical. It automatically caches the Maven local repository, typically saving 30-60 seconds per build. On our team's e-commerce platform, this meant the difference between a 2-minute build and a 5-minute build across dozens of daily commits.
For larger monorepos or when you need more control, you can use explicit cache actions:
- name: Cache Maven packages
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-This approach lets you share caches across workflows and gives you visibility into what's being cached. The hashFiles('**/pom.xml') ensures we invalidate the cache when dependencies change, preventing stale artifacts from causing obscure bugs.
Running tests with Testcontainers on ubuntu runners
Unit tests are a start, but integration tests catch real issues. We use Testcontainers to spin up databases, message queues, and other services during CI runs. The Ubuntu runners work well for this because Docker comes pre-installed.
Here's a practical test configuration that we use across our SaaS products:
- name: Run integration tests with Testcontainers
run: mvn verify -Dgroups="integration"
env:
TESTCONTAINERS_HOST_OVERRIDE: localhostIn your test code, you'd have something like this:
@SpringBootTest
@Testcontainers
public class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer postgres =
new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void shouldPersistUserSuccessfully() {
User user = new User("alice@example.com", "Alice");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getEmail()).isEqualTo("alice@example.com");
}
}Testcontainers automatically manages the container lifecycle—no manual cleanup needed. On Ubuntu runners, this typically adds 15-30 seconds per test suite. We tag tests using JUnit 5's @Tag annotation and run them conditionally to keep feedback loops tight. Fast unit tests run on every PR, integration tests run on merge to main.
One important detail: make sure your pom.xml includes Testcontainers with the appropriate profile:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>Building and pushing the image on main
After tests pass, we build a Docker image and push it to a registry. We only do this on the main branch to avoid cluttering your registry with every feature branch.
Here's a complete workflow step that builds and pushes to Docker Hub or Amazon ECR:
publish:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
run: mvn clean package -DskipTests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: myorg/myapp:${{ github.sha }},myorg/myapp:latest
cache-from: type=registry,ref=myorg/myapp:buildcache
cache-to: type=registry,ref=myorg/myapp:buildcache,mode=maxThe needs: build ensures this job only runs after the build job succeeds. Using github.sha as a tag gives us immutable references to every build. We tag with both the commit SHA and latest so developers can quickly reference the most recent version.
For ECR, substitute the login action:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.login-ecr.outputs.registry }}/myapp:${{ github.sha }}Docker layer caching dramatically speeds up subsequent builds. The cache-from and cache-to parameters ensure we reuse layers across CI runs. This can reduce build time from 5 minutes to 1-2 minutes when dependencies haven't changed.
Uploading JaCoCo and test reports
Code coverage metrics matter, but only if we actually track them. JaCoCo integrates seamlessly with Maven and GitHub Actions. We upload reports for visibility and trend tracking.
First, configure JaCoCo in your pom.xml:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>Then add these steps to your workflow:
- name: Run tests and generate coverage
run: mvn clean verify
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./target/site/jacoco/jacoco.xml
flags: unittests
name: codecov-umbrella
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: |
target/surefire-reports/
target/site/jacoco/The if: always() ensures we upload reports even if tests fail—critical for debugging. Codecov integrates with GitHub and shows coverage trends over time. We set coverage gates in our branch protection rules so we can't merge PRs that decrease coverage below thresholds.
For Surefire test reports, many teams use this action to get inline comments on PRs:
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: target/surefire-reports/**/*.xmlThis gives developers immediate feedback without leaving GitHub.
Common mistakes
Skipping tests on publish: We often see -DskipTests when building images. This defeats the purpose of CI. If tests pass on the build job, the publish job can safely skip them—use -DskipTests=true only when you explicitly need to rebuild a tested artifact.
Cache invalidation issues: If your pom.xml references snapshot versions or your Maven cache doesn't clear stale artifacts, you'll get mysterious "works locally, fails in CI" scenarios. Always hash your pom.xml files and consider periodic cache clears.
Missing Docker credentials: Pushing to private registries requires proper authentication. Store credentials as GitHub secrets and never commit them. Rotate tokens regularly.
No failure notifications: A workflow that silently fails is worse than no workflow. Add notifications to Slack or email so the team knows when main is broken.
Ignoring coverage trends: A coverage report nobody reads is useless. Set up alerts when coverage drops below thresholds and make coverage visible in your team's sprint reviews.
Putting it together: complete workflow example
Here's a production-ready workflow that combines everything:
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build and test
run: mvn clean verify
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./target/site/jacoco/jacoco.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: target/surefire-reports/
publish:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
run: mvn clean package -DskipTests
- name: Log in to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latestKey takeaways
- Use
actions/setup-javawith Maven caching enabled to accelerate builds; hash your pom.xml files for reliable cache invalidation. - Run integration tests with Testcontainers on Ubuntu runners to catch database and service integration issues before production.
- Publish Docker images only from main after all tests pass; tag with both commit SHA and
latestfor traceability. - Upload JaCoCo coverage reports to Codecov and test results as artifacts; use inline comments on PRs for immediate developer feedback.
- Always run tests before publishing—use
-DskipTestsonly when rebuilding a previously tested artifact, never to avoid CI delays. - Make coverage trends visible and set enforcement gates so the team takes code quality seriously.






Comments
Loading comments…