How to build a production-grade CI/CD pipeline for Spring Boot microservices using GitHub Actions — build, test, Docker image publishing, environment promotion, and the patterns that keep pipelines fast and reliable.
A CI/CD pipeline is as much a part of the system as the application code. A slow pipeline creates pressure to skip it; a broken pipeline blocks the whole team. At DWP Digital, the JSA platform CI/CD runs in under 10 minutes for most changes — build, unit tests, integration tests, Docker publish, and deployment to staging. This post covers how to build a pipeline at that level with GitHub Actions.
These examples assume a single Spring Boot service per repository. For monorepos with multiple services, the path filter approach in the workflow triggers applies per service — I’ll call that out where relevant.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build and unit test
run: mvn -B verify -DskipITs
- name: Integration tests
run: mvn -B verify -Dit.test="**/*IT" -Dsurefire.skip=true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/surefire-reports/
The cache: 'maven' in setup-java caches the local Maven repository between runs — saves 30–60 seconds on most builds by not re-downloading dependencies. The separation of unit tests (-DskipITs) and integration tests (-Dit.test) lets you fail fast on unit test failures before spinning up Testcontainers.
For finer control over the cache key — for example, busting the cache when pom.xml changes but not on every commit:
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
The hashFiles('**/pom.xml') key means the cache is invalidated whenever any pom.xml changes — ensuring you always build with up-to-date dependencies after a dependency update, while caching hits on every other commit.
On merge to main, build a Docker image and push to a container registry:
publish-image:
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write # required for GitHub Container Registry
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build JAR
run: mvn -B package -DskipTests
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha / cache-to: type=gha,mode=max enables Docker layer caching via GitHub Actions cache — Docker only rebuilds changed layers, which cuts image build time significantly for images with stable base layers.
Use a multi-stage build to keep the production image small:
# Stage 1: build
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:resolve -q
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Stage 2: runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The dependency resolution step (mvnw dependency:resolve) is split from the source compilation so Docker can cache the dependency layer independently. If only source files change, Docker skips the dependency download entirely.
Structure deployment as a separate workflow triggered after a successful image publish:
# .github/workflows/deploy.yml
name: Deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
jobs:
deploy-staging:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
environment: staging
steps:
- name: Deploy to staging
run: |
IMAGE_TAG=$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)
curl -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"image\": \"ghcr.io/${{ github.repository }}:${IMAGE_TAG}\", \"env\": \"staging\"}"
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production # requires manual approval in GitHub Environments settings
steps:
- name: Deploy to production
run: |
IMAGE_TAG=$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)
curl -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"image\": \"ghcr.io/${{ github.repository }}:${IMAGE_TAG}\", \"env\": \"production\"}"
The environment: production gate in GitHub Actions lets you configure required reviewers — a specific person or team must approve the deployment before it proceeds. This is the production approval gate, enforced at the CI/CD layer rather than by convention.
Add automated quality checks that run on every PR:
code-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for SonarCloud
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run tests with coverage
run: mvn -B verify -Pcoverage
- name: SonarCloud analysis
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: mvn -B sonar:sonar
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.host.url=https://sonarcloud.io
- name: Check coverage threshold
run: |
COVERAGE=$(mvn -B jacoco:report | grep -oP 'Total.*?\K[\d.]+(?=%)' | tail -1)
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage ${COVERAGE}% is below the 80% threshold"
exit 1
fi
Scan dependencies for known vulnerabilities on every build:
- name: Dependency vulnerability check
run: mvn -B org.owasp:dependency-check-maven:check
-DfailBuildOnCVSS=7
-DsuppressionFile=.github/dependency-check-suppressions.xml
failBuildOnCVSS=7 breaks the build for any dependency with a CVSS score of 7.0 or higher (high severity). The suppression file lets you acknowledge known false positives or accepted risks without failing the build on them.
A few practices that make a meaningful difference to pipeline duration:
Run tests in parallel. Maven Surefire supports parallel test execution:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
</configuration>
</plugin>
Use ubuntu-latest consistently. Mixed runner types (ubuntu, windows, macos) on the same repository add cache misses because caches are runner-specific.
Fail fast on the cheapest checks. Order jobs so linting and compilation run before integration tests. A compilation failure shouldn’t wait for Testcontainers to start.
Don’t reinstall tools that are already available. GitHub Actions ubuntu-latest runners come with Java, Docker, Maven, and most build tools pre-installed. Check the runner software list before adding a setup step.
A well-structured pipeline is invisible when it works — deployments just happen, PRs get feedback in minutes, and production only sees code that has passed every gate. That’s the standard worth building to.
If you’re setting up or improving CI/CD for Java microservices and want an engineer who builds pipelines that teams actually trust, get in touch.