CI/CD for Java Applications

Automate Your Builds, Tests, and Deployments with Jenkins and GitHub Actions

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

Master DevOps for Java with Expert Mentorship

Learn CI/CD, Docker, Kubernetes, and cloud deployment with hands-on projects.