Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate5 min readUpdated
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

Cheap checks first, then parallel analysis, then one image promoted through every environment.

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

.github/workflows/ci.yml
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

pom.xml
<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?
Under ten minutes to a deployable artefact, and ideally under five for the feedback that gates a pull request. Past fifteen minutes people stop waiting, start batching changes, and the value of continuous integration disappears. Treat pipeline duration as a metric you actively defend.
Should the pipeline deploy to production automatically?
Continuous delivery — every green build is deployable — is the goal for everyone. Continuous deployment — every green build actually deploys — needs strong automated verification and fast rollback. Start with an approval gate on production and remove it once you trust the tests and the rollback path.
Why is my cache not helping?
Usually the cache key. Keying on a file that changes every commit means a miss every time; keying on nothing means a stale cache. Key on a hash of the dependency file, with a restore-key prefix so a near-miss still gets a partial hit.

Related tutorials