What is CI/CD?
Imagine you're baking cookies for a bakery. Would you bake all 1000 cookies, then check if the recipe works? Or would you test a small batch first, perfect the recipe, then automate the process?
CI/CD is the automated assembly line for your code. Every time you make changes, the system automatically builds, tests, and deploys your application - catching problems early before they reach customers.
CI - Continuous Integration
Automatically build and test code whenever developers push changes. Catch bugs early, before they pile up.
CD - Continuous Delivery
Automatically prepare releases. Code is always ready to deploy with one click.
CD - Continuous Deployment
Go further: automatically deploy to production. No manual steps needed.
Industry Standard
Every professional team uses CI/CD. It's expected in modern software development.
Anatomy of a CI/CD Pipeline
A typical Java pipeline has these stages:
Developer pushes code
↓
┌─────────────────────────────────────────────────────────┐
│ CI/CD Pipeline │
├─────────────────────────────────────────────────────────┤
│ 1. CHECKOUT - Get the latest code │
│ ↓ │
│ 2. BUILD - Compile with Maven/Gradle │
│ ↓ │
│ 3. TEST - Run unit tests │
│ ↓ │
│ 4. CODE QUALITY - SonarQube, Checkstyle │
│ ↓ │
│ 5. PACKAGE - Create JAR/Docker image │
│ ↓ │
│ 6. DEPLOY - Push to staging/production │
└─────────────────────────────────────────────────────────┘
↓
Application running in production!
GitHub Actions: The Modern Choice
GitHub Actions is built into GitHub - no separate server needed. Perfect for open source and most projects.
Basic Java Workflow
Create this file at .github/workflows/ci.yml:
name: Java CI/CD Pipeline
# When to run
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Get the code
- name: Checkout code
uses: actions/checkout@v4
# Step 2: Set up Java
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven # Cache dependencies
# Step 3: Build and test
- name: Build with Maven
run: mvn clean verify
# Step 4: Upload test results
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: target/surefire-reports/
# Step 5: Upload JAR
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/*.jar
Complete Pipeline with Deployment
name: Full CI/CD Pipeline
on:
push:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Job 1: Build and Test
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Get version
id: version
run: echo "version=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT
- name: Build and test
run: mvn clean verify
- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: app
path: target/*.jar
# Job 2: Build Docker Image
docker:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download JAR
uses: actions/download-artifact@v4
with:
name: app
path: target/
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build.outputs.version }}
# Job 3: Deploy to Staging
deploy-staging:
needs: docker
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging
run: |
echo "Deploying to staging..."
# kubectl apply -f k8s/staging/
# Or: ssh deploy@staging-server 'docker pull ... && docker-compose up -d'
# Job 4: Deploy to Production (manual approval)
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requires approval in GitHub settings
steps:
- name: Deploy to production
run: |
echo "Deploying to production..."
# kubectl apply -f k8s/production/
Matrix Testing (Multiple Java Versions)
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '11', '17', '21' ]
steps:
- uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
cache: maven
- name: Test with JDK ${{ matrix.java }}
run: mvn test
Jenkins: The Enterprise Standard
Jenkins is self-hosted and highly customizable. Popular in enterprises with complex requirements.
Jenkinsfile (Declarative Pipeline)
Create a Jenkinsfile in your project root:
pipeline {
agent any
tools {
maven 'Maven-3.9'
jdk 'JDK-17'
}
environment {
DOCKER_REGISTRY = 'your-registry.com'
APP_NAME = 'myapp'
}
stages {
stage('Checkout') {
steps {
checkout scm
echo "Building branch: ${env.BRANCH_NAME}"
}
}
stage('Build') {
steps {
sh 'mvn clean compile'
}
}
stage('Test') {
steps {
sh 'mvn test'
}
post {
always {
junit 'target/surefire-reports/*.xml'
}
}
}
stage('Code Quality') {
steps {
withSonarQubeEnv('SonarQube') {
sh 'mvn sonar:sonar'
}
}
}
stage('Package') {
steps {
sh 'mvn package -DskipTests'
archiveArtifacts artifacts: 'target/*.jar'
}
}
stage('Build Docker Image') {
steps {
script {
def image = docker.build("${DOCKER_REGISTRY}/${APP_NAME}:${env.BUILD_NUMBER}")
docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-credentials') {
image.push()
image.push('latest')
}
}
}
}
stage('Deploy to Staging') {
when {
branch 'develop'
}
steps {
sh '''
kubectl config use-context staging
kubectl set image deployment/${APP_NAME} ${APP_NAME}=${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER}
'''
}
}
stage('Deploy to Production') {
when {
branch 'main'
}
input {
message "Deploy to production?"
ok "Deploy"
}
steps {
sh '''
kubectl config use-context production
kubectl set image deployment/${APP_NAME} ${APP_NAME}=${DOCKER_REGISTRY}/${APP_NAME}:${BUILD_NUMBER}
'''
}
}
}
post {
success {
slackSend channel: '#deployments',
color: 'good',
message: "Build succeeded: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
}
failure {
slackSend channel: '#deployments',
color: 'danger',
message: "Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
}
always {
cleanWs()
}
}
}
Parallel Stages
stage('Tests') {
parallel {
stage('Unit Tests') {
steps {
sh 'mvn test -Dtest=*UnitTest'
}
}
stage('Integration Tests') {
steps {
sh 'mvn test -Dtest=*IntegrationTest'
}
}
stage('Security Scan') {
steps {
sh 'mvn dependency-check:check'
}
}
}
}
Maven Configuration for CI/CD
Essential Plugins
<!-- pom.xml -->
<build>
<plugins>
<!-- Compiler plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<!-- Surefire for unit tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<!-- Failsafe for integration tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.1.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- JaCoCo for code coverage -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Spring Boot plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Profiles for Different Environments
<profiles>
<profile>
<id>ci</id>
<properties>
<skipTests>false</skipTests>
</properties>
</profile>
<profile>
<id>quick</id>
<properties>
<skipTests>true</skipTests>
<maven.javadoc.skip>true</maven.javadoc.skip>
</properties>
</profile>
</profiles>
<!-- Usage: mvn package -Pci -->
Code Quality Gates
SonarQube Integration
# GitHub Actions
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
# Or with Maven
- name: SonarQube Scan
run: |
mvn sonar:sonar \
-Dsonar.projectKey=myapp \
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
-Dsonar.login=${{ secrets.SONAR_TOKEN }}
Quality Gate Check
# sonar-project.properties sonar.projectKey=myapp sonar.projectName=My Application sonar.sources=src/main/java sonar.tests=src/test/java sonar.java.binaries=target/classes sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml # Quality gate thresholds sonar.qualitygate.wait=true
Enforce Code Coverage
<!-- Fail build if coverage < 80% -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Managing Secrets
GitHub Actions Secrets
# In your workflow, access secrets like this:
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
# Or in steps:
- name: Deploy
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
echo "$KUBE_CONFIG" > kubeconfig
kubectl --kubeconfig=kubeconfig apply -f k8s/
Jenkins Credentials
// In Jenkinsfile
withCredentials([
string(credentialsId: 'api-key', variable: 'API_KEY'),
usernamePassword(credentialsId: 'docker-creds',
usernameVariable: 'DOCKER_USER',
passwordVariable: 'DOCKER_PASS')
]) {
sh '''
docker login -u $DOCKER_USER -p $DOCKER_PASS
# Use $API_KEY here
'''
}
CI/CD Best Practices
1. Fail Fast
Run quick checks first. Don't wait for a 30-minute integration test to fail on a typo.
# Order: Compile → Unit Tests → Integration Tests → Deploy stages: - compile # 30 seconds - unit-test # 2 minutes - integration # 10 minutes - deploy # 5 minutes
2. Cache Dependencies
# GitHub Actions
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: maven # Automatically caches ~/.m2
# Or manually
- uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
3. Use Branch Protection
Require CI to pass before merging. In GitHub: Settings → Branches → Add rule.
4. Keep Pipelines DRY
# GitHub Actions: Reusable workflows
# .github/workflows/java-build.yml
name: Java Build
on:
workflow_call: # Can be called from other workflows
inputs:
java-version:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: ${{ inputs.java-version }}
distribution: 'temurin'
- run: mvn verify
# Use in another workflow
jobs:
call-build:
uses: ./.github/workflows/java-build.yml
with:
java-version: '17'
5. Notify on Failure
# Slack notification
- name: Notify Slack on Failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
channel: '#builds'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
GitHub Actions vs Jenkins
Choose GitHub Actions When:
- Your code is on GitHub
- You want zero infrastructure to manage
- You need quick setup (minutes, not hours)
- Open source or small team projects
Choose Jenkins When:
- You need complex, custom pipelines
- You're in an enterprise with specific requirements
- You need to run on-premises for security
- You have existing Jenkins infrastructure