Hire Me
← All Writing DevOps

CI/CD for Spring Boot Microservices with GitHub Actions

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.

Repository Structure Assumption

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.

The Core Workflow

# .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.

Caching Maven Dependencies Explicitly

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.

Docker Image Build and Publish

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.

The Dockerfile

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.

Environment Promotion: Staging and Production

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.

Pull Request Quality Gates

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

Security Scanning

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.

Keeping Pipelines Fast

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.