CI/CD Pipeline Design for Java
A pipeline that stays fast as the codebase grows: stage design, GitHub Actions with dependency and Docker caching, test parallelisation, and quality gates that catch real problems.
On this page
A pipeline is a product your team uses dozens of times a day. When it is fast and trustworthy, people push small changes often; when it is slow or flaky, they batch work and skip it — which is where integration problems come from.
Key Takeaways
- Order stages cheapest-first so an obvious failure costs seconds, not minutes.
- Cache dependencies and Docker layers; a cold build every time is the main cause of slow pipelines.
- Parallelise independent jobs — compile, lint and security scan need not be sequential.
- Build the artefact once and promote the same one through every environment.
- A flaky test is worse than no test, because it teaches people to ignore red.
Stage design
Two principles carry most of the value.
Fail fast on cheap things. A compilation error should be reported in ninety seconds, not after a six-minute integration suite. Order stages by cost, and run the parallel group only once the basics pass.
Build once, promote everywhere. The artefact tested in staging must be the exact bytes deployed to production. Rebuilding per environment means production runs something no one tested — a dependency resolved differently, a base image updated between builds.
A pipeline worth copying
name: CI
on:
push: { branches: [main] }
pull_request:
concurrency:
# Cancel superseded runs on the same branch — no point testing a stale commit.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: temurin
# Keys on pom.xml hashes automatically and restores partial matches.
cache: maven
- name: Compile and unit test
run: ./mvnw -B -T 1C verify -DskipITs
- uses: actions/upload-artifact@v4
with:
name: jar
path: target/*.jar
retention-days: 5
analysis:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # SonarQube needs history for new-code analysis
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: temurin, cache: maven }
- run: ./mvnw -B verify sonar:sonar -Dsonar.projectKey=acme_order-service
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
integration:
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
# Split the suite across four runners; each takes a quarter of the time.
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: temurin, cache: maven }
- run: ./mvnw -B verify -Dtest.shard=${{ matrix.shard }} -Dtest.shards=4
image:
runs-on: ubuntu-latest
needs: [analysis, integration]
permissions: { contents: read, packages: write, id-token: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
# Immutable tag. 'latest' makes "what is running?" unanswerable.
tags: ghcr.io/acme/order-service:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/acme/order-service:${{ github.sha }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
ignore-unfixed: true
- name: Sign
run: cosign sign --yes ghcr.io/acme/order-service:${{ github.sha }}That closing cosign sign is only half of a supply-chain control. A signature nobody verifies proves
nothing, so pair it with an admission policy in the cluster — Kyverno or the Sigstore policy
controller — that refuses to run any image lacking a valid signature from your CI identity. Signing on
its own is a checkbox on an audit form; signing plus admission is what actually stops an unreviewed
image from running.
Making it fast
Cache dependencies. cache: maven in setup-java keys on the hash of pom.xml and restores
partial matches, so a new dependency does not invalidate the whole cache.
Cache Docker layers. type=gha stores BuildKit layers in the Actions cache. Combined with a
Dockerfile that resolves dependencies in a separate layer, an image rebuild after a code-only change
takes seconds.
Parallelise the build itself. -T 1C runs one Maven thread per core, which is a large win on
multi-module projects. Gradle's --parallel plus the build cache does the same.
Shard the tests. Integration tests dominate pipeline time and are embarrassingly parallel across runners. Four shards turn twelve minutes into three, for the price of four runners.
Cancel superseded runs. The concurrency block stops CI testing a commit that has already been
replaced — pure waste that also queues behind real work.
Quality gates
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireMavenVersion><version>[3.9,)</version></requireMavenVersion>
<!-- A version conflict resolved silently is a class of bug that
only appears at runtime, in production, rarely. -->
<dependencyConvergence/>
<banDuplicatePomDependencyVersions/>
</rules>
</configuration>
</execution>
</executions>
</plugin>Gates worth enforcing: coverage on new code rather than overall (a legacy codebase will never reach 80%, but new code can), no new high-severity static-analysis findings, no dependencies with known critical CVEs that have a fix available, and dependency convergence.
Gates that cause more harm than good: a global coverage threshold that encourages tests written to satisfy a number, and blocking on unfixable upstream CVEs, which teaches people to disable the scan.
Flaky tests
A test that fails one run in twenty is worse than no test. People learn to re-run rather than investigate, and eventually a real failure gets re-run too.
Treat flakiness as a bug with an owner. Quarantine the test — keep it running but non-blocking, in a tracked list — fix it within a sprint, or delete it. What does not work is leaving it in the blocking suite and telling everyone to re-run.
Most Java flakiness has three causes: a fixed Thread.sleep instead of a polled condition, shared
mutable state between tests that only breaks under parallel execution, and a dependency on wall-clock
time or ordering. All three are fixable.
What to take away
Order stages cheapest-first, cache dependencies and Docker layers, shard slow suites across runners, and cancel superseded runs. Build one immutable artefact and promote it. Gate on new-code quality rather than global numbers, and treat a flaky test as a bug rather than an inconvenience.
Frequently Asked Questions
How fast should a pipeline be?
Should the pipeline deploy to production automatically?
Why is my cache not helping?
Related tutorials
- Docker in CI/CD & ProductionContainer builds that are fast and trustworthy: BuildKit cache and secret mounts, tagging strategy, registry choice, vulnerability scanning, SBOM generation and image signing.
- Kubernetes Deployment StrategiesShipping without downtime: rolling update mechanics, blue-green switching, canary with automated analysis, and GitOps reconciliation with Argo CD.
- Infrastructure as Code for Java AppsProvisioning the infrastructure a Java service needs: Terraform state and locking, reusable modules, managed databases and brokers, and where Pulumi fits.
- Production Observability — Full StackAssembling a production observability stack: the OTel agent and collector pipelines, Mimir, Loki and Tempo, alerting strategy that avoids fatigue, and runbooks that get used.