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
mvnwwrapper) - 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@v4withcache: 'maven'— Installs Temurin JDK 21 and automatically caches~/.m2/repositorykeyed on the hash of allpom.xmlfiles. 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:
docker pullfetches the freshly builtlatestimage from Docker Hub.docker stop/docker rmshuts down the running container. The|| trueprevents failure on the very first deploy when no container exists yet.docker run -d --restart unless-stoppedstarts the new container in the background, and the restart policy ensures it survives server reboots automatically.SPRING_PROFILES_ACTIVE=prodloadsapplication-prod.ymlfor 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 Name | What to put here |
|---|---|
DOCKERHUB_USERNAME | Your Docker Hub username |
DOCKERHUB_TOKEN | Docker Hub access token — create one at hub.docker.com/settings/security (not your password) |
SERVER_HOST | IP address or hostname of your deployment server |
SERVER_USER | SSH username on that server (e.g. ubuntu, deploy) |
SERVER_SSH_KEY | Contents 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:
- Validates every pull request automatically — no broken code merges to
main - Packages a lean multi-stage Docker image using only the JRE (~100 MB) in production
- Publishes immutable SHA-tagged images alongside a rolling
latesttag to Docker Hub - 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.