Post

GitHub Actions CI/CD for Spring Boot — Build, Test, Deploy

Build a production CI/CD pipeline for Spring Boot with GitHub Actions. Covers build + test, Docker image publishing, Kubernetes deployment, environment secrets, caching for fast builds, and branch protection strategies.

GitHub Actions CI/CD for Spring Boot — Build, Test, Deploy

The Problem — Manual Builds

Manual deployment processes create friction and risk:

  • “It works on my machine” becomes a recurring problem
  • No automated tests catch regressions before merge
  • Docker builds are inconsistent across team members
  • Deployments require tribal knowledge and manual steps
  • No audit trail of who deployed what and when

A proper CI/CD pipeline automates the path from commit to production, catching issues early and deploying reliably every time.


What We’re Building

graph LR
    A[Push to Branch] --> B[Build + Test]
    B --> C[Docker Image]
    C --> D[Push to GHCR]
    D --> E{Branch?}
    E -->|main| F[Deploy to Staging]
    E -->|release/*| G[Deploy to Production]
    F --> H[Smoke Tests]
    H --> I[Ready for Promotion]

diagram

The complete pipeline:

  1. Build & Test — Compile, run unit + integration tests
  2. Docker Build — Create optimized container image
  3. Publish — Push to GitHub Container Registry (GHCR)
  4. Deploy — Apply to Kubernetes cluster
  5. Verify — Run smoke tests against deployed service

Basic Workflow — Build and Test

Start with the foundation: compile code and run tests on every push and pull request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# .github/workflows/ci.yml
name: CI — Build & Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  contents: read
  checks: write

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Build and test
        run: ./mvnw verify --batch-mode --no-transfer-progress

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: target/surefire-reports/

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: target/site/jacoco/

Key points:

  • actions/setup-java@v4 with cache: 'maven' automatically caches ~/.m2/repository
  • --batch-mode disables interactive prompts
  • --no-transfer-progress reduces log noise
  • Test results upload even on failure (if: always())

Adding Docker Build

Using a Multi-stage Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline --batch-mode
COPY src src
RUN ./mvnw package -DskipTests --batch-mode

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]

Using Jib (No Dockerfile Needed)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- pom.xml -->
<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.4</version>
    <configuration>
        <from>
            <image>eclipse-temurin:21-jre-alpine</image>
        </from>
        <to>
            <image>ghcr.io/${env.GITHUB_REPOSITORY}</image>
            <tags>
                <tag>${project.version}</tag>
                <tag>latest</tag>
            </tags>
        </to>
    </configuration>
</plugin>

Publishing to GHCR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# .github/workflows/publish.yml
name: Build & Publish Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run tests
        run: ./mvnw verify --batch-mode

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: $/$
          tags: |
            type=ref,event=branch
            type=semver,pattern=
            type=sha,prefix=

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: $
          labels: $
          cache-from: type=gha
          cache-to: type=gha,mode=max

Tags generated:

  • Push to main → ghcr.io/user/repo:main
  • Tag v1.2.3 → ghcr.io/user/repo:1.2.3
  • Every push → ghcr.io/user/repo:<sha>

Deploying to Kubernetes

Kubernetes Manifests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# k8s/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
    spec:
      containers:
        - name: spring-app
          image: ghcr.io/anupamsinha/spring-app:IMAGE_TAG
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "production"
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: db-password
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "1000m"

Deploy Job in Workflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: staging

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/k8s-set-context@v4
        with:
          kubeconfig: $

      - name: Update image tag
        run: |
          sed -i "s|IMAGE_TAG|$|g" k8s/deployment.yml

      - name: Deploy to cluster
        run: |
          kubectl apply -f k8s/deployment.yml
          kubectl rollout status deployment/spring-app -n production --timeout=300s

      - name: Smoke test
        run: |
          ENDPOINT=$(kubectl get svc spring-app -n production -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
          curl --fail --retry 5 --retry-delay 10 "http://${ENDPOINT}:8080/actuator/health"

Environment Secrets

Configure secrets in GitHub repository settings (Settings → Secrets → Actions):

SecretPurposeExample
KUBE_CONFIGKubernetes cluster authBase64-encoded kubeconfig
DB_PASSWORDDatabase credentialsProduction DB password
SONAR_TOKENCode quality analysisSonarCloud token
SLACK_WEBHOOK_URLDeployment notificationsSlack incoming webhook

Use environments for deployment approvals:

1
2
3
4
5
6
jobs:
  deploy-production:
    environment:
      name: production
      url: https://app.example.com
    # Requires manual approval from designated reviewers

Matrix Testing — Java 17 and 21

Test against multiple Java versions to ensure compatibility:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java-version: ['17', '21']
      fail-fast: false

    steps:
      - uses: actions/checkout@v4

      - name: Set up Java $
        uses: actions/setup-java@v4
        with:
          java-version: $
          distribution: 'temurin'
          cache: 'maven'

      - name: Test with Java $
        run: ./mvnw verify --batch-mode

fail-fast: false ensures all matrix combinations run even if one fails — useful for knowing the full scope of compatibility issues.


Dependency Caching

Maven dependencies download once and are cached for subsequent runs:

1
2
3
4
5
6
      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'  # Automatically caches ~/.m2/repository

For finer control or additional caches:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
      - name: Cache Maven dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.m2/repository
            !~/.m2/repository/com/anupam
          key: maven-$
          restore-keys: |
            maven-

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: docker-$
          restore-keys: |
            docker-

Impact on build times:

ScenarioWithout CacheWith CacheSavings
Fresh Maven dependencies2-4 min10-20 sec~90%
Docker layer rebuild3-5 min30-60 sec~80%
Full pipeline8-12 min3-5 min~60%

Branch Strategy

graph TD
    A[feature/xyz] -->|PR| B[main]
    B -->|auto-deploy| C[Staging]
    C -->|manual approval| D[Production]

    E[hotfix/abc] -->|PR| B

diagram

BranchTriggerAction
feature/*Push, PRBuild + Test only
mainMergeBuild + Test + Deploy to Staging
release/*TagBuild + Test + Deploy to Production
hotfix/*PR to mainBuild + Test (fast-tracked review)

Branch protection rules (Settings → Branches → Add rule):

  • Require status checks to pass (CI must be green)
  • Require pull request reviews (at least 1 approval)
  • Require branches to be up to date before merging
  • Restrict force pushes

Reusable Workflows

Extract common steps into reusable workflows to avoid duplication:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .github/workflows/reusable-build.yml
name: Reusable Build

on:
  workflow_call:
    inputs:
      java-version:
        required: false
        type: string
        default: '21'
    outputs:
      image-tag:
        description: "Docker image tag"
        value: $

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: $
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: $
          distribution: 'temurin'
          cache: 'maven'
      - run: ./mvnw verify --batch-mode

Call from another workflow:

1
2
3
4
5
6
7
8
9
10
11
12
# .github/workflows/deploy.yml
jobs:
  build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      java-version: '21'

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying image tag $"

Complete Production Workflow

Putting it all together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# .github/workflows/pipeline.yml
name: Production Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  packages: write
  checks: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'
      - name: Build and test
        run: ./mvnw verify --batch-mode --no-transfer-progress
      - name: Publish test results
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: JUnit Tests
          path: target/surefire-reports/*.xml
          reporter: java-junit

  publish:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    outputs:
      image-tag: $
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: $/$
          tags: |
            type=sha,prefix=
            type=raw,value=latest
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: $
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: publish
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: azure/k8s-set-context@v4
        with:
          kubeconfig: $
      - run: |
          sed -i "s|IMAGE_TAG|$|g" k8s/deployment.yml
          kubectl apply -f k8s/deployment.yml
          kubectl rollout status deployment/spring-app --timeout=300s

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4
      - uses: azure/k8s-set-context@v4
        with:
          kubeconfig: $
      - run: |
          sed -i "s|IMAGE_TAG|$|g" k8s/deployment.yml
          kubectl apply -f k8s/deployment.yml
          kubectl rollout status deployment/spring-app --timeout=300s

Common Problems

ProblemCauseSolution
Tests pass locally, fail in CIEnvironment differencesPin Java version; use testcontainers for DB
Slow builds (10+ min)No caching; sequential stepsEnable Maven cache; parallelize test/lint jobs
Docker push failsMissing GHCR permissionsAdd packages: write to permissions
Deploy timeoutApp takes too long to startIncrease initialDelaySeconds; optimize startup
Secrets not available in PRsFork PRs can’t access secretsUse pull_request_target cautiously; or skip deploy for PRs
Cache keeps missingKey changes every runUse hashFiles('**/pom.xml') as stable cache key
Flaky testsTiming issues, shared stateIsolate tests; add retries for integration tests

References

This post is licensed under CC BY 4.0 by the author.