CI/CD Pipeline with GitHub Actions for Spring Boot 3.5: From Code to Production

Introduction

Shipping software is only half the battle — getting it to production reliably, repeatably, and without manual intervention is the other half. A well-designed CI/CD (Continuous Integration / Continuous Deployment) pipeline closes that gap: developers push code, production updates itself.

In this tutorial you will build a complete CI/CD pipeline for a Spring Boot 3.5 REST API using GitHub Actions. By the end you will have:

  • A production-ready Spring Boot 3.5.15 REST API with full CRUD, bean validation, and Actuator health endpoints
  • A multi-stage Dockerfile that produces a lean JRE-only image (~100 MB)
  • A GitHub Actions CI workflow that runs the full test suite on every push and pull request
  • A GitHub Actions CD workflow that builds a Docker image, pushes it to Docker Hub, and SSH-deploys to a server — automatically triggered only when CI passes
  • Secure credential management via GitHub Secrets
  • Cached Maven builds that keep pipeline runs under two minutes

Stack: Spring Boot 3.5.15 · Java 21 · Maven · Docker · GitHub Actions · Docker Hub


Prerequisites

  • Java 21 installed locally (LTS, required by Spring Boot 3.5)
  • Maven 3.9+ on your PATH (or use the bundled mvnw wrapper)
  • Docker Desktop installed and running
  • A GitHub repository for the project
  • A Docker Hub account (free tier is sufficient)
  • A Linux VPS with Docker installed — for the deployment stage (optional if you only want CI)

Project Setup

Generate a project at start.spring.io with the settings below, or use Spring Boot CLI:

spring init \
  --boot-version=3.5.15 \
  --java-version=21 \
  --dependencies=web,actuator,data-jpa,h2,validation \
  --group-id=com.vimleshpandey.demo \
  --artifact-id=cicd-demo \
  cicd-demo

Your pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
           https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.15</version>
    <relativePath/>
  </parent>

  <groupId>com.vimleshpandey.demo</groupId>
  <artifactId>cicd-demo</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>cicd-demo</name>
  <description>Spring Boot CI/CD Demo with GitHub Actions</description>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

src/main/resources/application.yml:

spring:
  application:
    name: cicd-demo
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
  h2:
    console:
      enabled: false

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: when-authorized

server:
  port: 8080

Building the Application

Main Application Class

package com.vimleshpandey.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CicdDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CicdDemoApplication.class, args);
    }
}

Product Entity

package com.vimleshpandey.demo.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Name must not be blank")
    @Column(nullable = false)
    private String name;

    @Positive(message = "Price must be positive")
    @Column(nullable = false)
    private double price;

    public Product() {}
    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
}

Repository and Service

// ProductRepository.java
package com.vimleshpandey.demo.repository;

import com.vimleshpandey.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {}
// ProductService.java
package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class ProductService {

    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    public List<Product> findAll()                { return repository.findAll(); }
    public Optional<Product> findById(Long id)    { return repository.findById(id); }
    public Product save(Product product)           { return repository.save(product); }
    public void delete(Long id)                    { repository.deleteById(id); }
}

REST Controller

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public List<Product> getAll() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getById(@PathVariable Long id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product create(@Valid @RequestBody Product product) {
        return service.save(product);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

Writing the Dockerfile

A multi-stage build keeps the final image lean — the JDK (needed to compile) stays in the build stage; only the smaller JRE ships to production.

# Build stage: JDK to compile and package
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src ./src
RUN ./mvnw package -DskipTests -B

# Runtime stage: JRE only (~100 MB vs ~400 MB with full JDK)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Create .dockerignore in the project root:

target/
.git/
*.md
.github/

Why copy pom.xml before src/? Docker layer caching. If only source files change, Docker skips the dependency download entirely and reuses the cached Maven layer. This cuts Docker build times from 3–4 minutes to under 30 seconds on subsequent builds.


Setting Up GitHub Actions: The CI Workflow

Create .github/workflows/ci.yml. This workflow runs on every push and pull request targeting main:

name: CI — Build & Test

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

jobs:
  test:
    name: Build & Test
    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: Run tests
        run: ./mvnw verify -B

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

What each step does:

  • actions/setup-java@v4 with cache: 'maven' — Installs Temurin JDK 21 and automatically caches ~/.m2/repository keyed on the hash of all pom.xml files. Subsequent runs skip dependency downloads entirely unless a POM changes.
  • ./mvnw verify -B — Runs compile → test → integration-test in one command. Batch mode (-B) suppresses Maven's interactive output for cleaner CI logs.
  • Upload test reports with if: always() — Even on failure, Surefire XML reports are uploaded as a downloadable artifact. Debugging test failures from the artifact is far faster than re-running locally.

The CD Workflow: Build, Push, and Deploy

Create .github/workflows/cd.yml. It is gated on CI passing — preventing broken code from ever reaching Docker Hub:

name: CD — Build, Push & Deploy

on:
  workflow_run:
    workflows: ["CI — Build & Test"]
    types: [completed]
    branches: [ main ]

jobs:
  deploy:
    name: Docker image & Deploy
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    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 JAR (tests already ran in CI)
        run: ./mvnw package -DskipTests -B

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo
          tags: |
            type=sha,prefix=,suffix=,format=short
            type=raw,value=latest,enable={{is_default_branch}}

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

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            docker pull ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo:latest
            docker stop cicd-demo 2>/dev/null || true
            docker rm cicd-demo 2>/dev/null || true
            docker run -d --name cicd-demo --restart unless-stopped -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod ${{ secrets.DOCKERHUB_USERNAME }}/cicd-demo:latest
            echo "Deploy complete: $(date)"

How the deploy script works:

  1. docker pull fetches the freshly built latest image from Docker Hub.

  2. docker stop / docker rm shuts down the running container. The || true prevents failure on the very first deploy when no container exists yet.

  3. docker run -d --restart unless-stopped starts the new container in the background, and the restart policy ensures it survives server reboots automatically.

  4. SPRING_PROFILES_ACTIVE=prod loads application-prod.yml for production database URLs and settings.

Image tagging strategy: docker/metadata-action creates two tags — an immutable short SHA (abc1234) pinning the exact commit, plus a rolling latest tag. You can roll back to any previous deploy by pulling its SHA tag.

Why workflow_run instead of push? If you trigger CD on push directly, both CI and CD start simultaneously. A failing test will not stop a deployment already in progress. Using workflow_run with conclusion == 'success' makes CI a hard gate.


Managing Secrets Securely

Navigate to GitHub → Settings → Secrets and variables → Actions → New repository secret and add:

Secret NameWhat to put here
DOCKERHUB_USERNAMEYour Docker Hub username
DOCKERHUB_TOKENDocker Hub access token — create one at hub.docker.com/settings/security (not your password)
SERVER_HOSTIP address or hostname of your deployment server
SERVER_USERSSH username on that server (e.g. ubuntu, deploy)
SERVER_SSH_KEYContents of the private SSH key for that server

Generate a dedicated deployment SSH key — never reuse your personal key:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
ssh-copy-id -i ~/.ssh/deploy_key.pub deploy@your-server
# Paste the PRIVATE key into GitHub as SERVER_SSH_KEY:
cat ~/.ssh/deploy_key

Testing

A thorough integration test covering the full HTTP stack using @SpringBootTest and MockMvc:

package com.vimleshpandey.demo;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.vimleshpandey.demo.model.Product;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ProductControllerIntegrationTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    void createAndRetrieveProduct() throws Exception {
        Product product = new Product("Widget Pro", 29.99);
        mockMvc.perform(post("/api/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(product)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("Widget Pro"));

        mockMvc.perform(get("/api/products"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].name").value("Widget Pro"))
                .andExpect(jsonPath("$[0].price").value(29.99));
    }

    @Test
    void validationRejectsBlankName() throws Exception {
        Product invalid = new Product("", 10.0);
        mockMvc.perform(post("/api/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(invalid)))
                .andExpect(status().isBadRequest());
    }

    @Test
    void returns404ForUnknownProduct() throws Exception {
        mockMvc.perform(get("/api/products/99999"))
                .andExpect(status().isNotFound());
    }

    @Test
    void healthEndpointIsUp() throws Exception {
        mockMvc.perform(get("/actuator/health"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("UP"));
    }
}

The @Transactional annotation on the test class rolls back every database change after each test method — tests are fully isolated without manually clearing H2.


Running the Application Locally

Verify everything works before your first push:

# Make mvnw executable (do this once)
chmod +x mvnw

# Run the full test suite
./mvnw verify

# Start the application
./mvnw spring-boot:run

# Test the REST API
curl http://localhost:8080/api/products
curl -s -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Widget","price":19.99}'

# Check the health endpoint
curl http://localhost:8080/actuator/health

# Build and run as Docker image
docker build -t cicd-demo:local .
docker run -p 8080:8080 cicd-demo:local

Common Pitfalls and Tips

1. Forget to make mvnw executable

GitHub Actions will fail with permission denied on the very first run. Fix it once, before your first push:

git update-index --chmod=+x mvnw
git commit -m "chore: make mvnw executable"

2. Docker layer cache order

Always copy pom.xml and resolve dependencies before copying src/. If you copy everything at once, any source file change forces Maven to re-download all dependencies from scratch — this turns a 30-second Docker build into a 4-minute one.

3. -DskipTests in CD is intentional

Tests already ran in the CI job. Skipping them in the CD build step eliminates a redundant 60–90 seconds from every deployment with zero trade-off in confidence.

4. Docker Hub access tokens, not passwords

Docker Hub deprecated password authentication for API use. Create a personal access token at hub.docker.com, assign it the "Read & Write" scope, and store it as DOCKERHUB_TOKEN. Your account password will return an authentication error.

5. Pin action versions to major tags

uses: actions/checkout@v4 is stable. uses: actions/checkout@latest can silently pull in breaking changes with no warning. Major version tags (@v4, @v3) are maintained to be non-breaking within their series.

6. Restrict Actuator in production

Your application-prod.yml should limit what endpoints are exposed:

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when-authorized

Never expose env, beans, or heapdump endpoints in production — they leak sensitive application internals.

7. Use cache-from: type=gha in Docker Buildx

GitHub Actions has a built-in cache backend for Docker layers (type=gha). With cache-from and cache-to configured, Docker reuses unchanged layers across pipeline runs — the base JRE layer is rarely rebuilt.


Conclusion

You now have a complete, production-grade CI/CD pipeline that:

  1. Validates every pull request automatically — no broken code merges to main
  2. Packages a lean multi-stage Docker image using only the JRE (~100 MB) in production
  3. Publishes immutable SHA-tagged images alongside a rolling latest tag to Docker Hub
  4. Deploys to your server the moment tests go green on main — zero manual steps

From here, natural next steps include adding a staging branch that deploys to a staging environment before main, using semantic versioning with docker/metadata-action to tag images from Git tags, and a post-deploy health check step that polls /actuator/health and rolls back automatically if the new container is unhealthy within 60 seconds.

Every commit you push from this point forward is production by default. Ship with confidence.