Building Modern Java Pipelines: From Code to Production Using Automated CI/CD

Building Modern Java Pipelines: From Code to Production Using Automated CI/CD

Source: Dev.to

This isn't magic. It's a set of connected, practical techniques that turn the chaotic process of software delivery into a predictable, repeatable, and collaborative engineering discipline. You start with a simple compile step and gradually add these layers. Each one builds confidence, reduces toil, and lets your team focus on writing features, not debugging deployments. ## 101 Books ## Our Creations ## We are on Medium As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world! Think of your Java application as a car being built on an assembly line. A modern build pipeline is that entire automated assembly line. It takes your raw code, puts it through various stages of assembly and quality checks, and finally produces a vehicle ready for the road. My goal is to show you how to build that assembly line so it’s fast, reliable, and doesn’t let anything broken slip through. This isn't just about running a Maven command. It's about creating a system that works for your team every single time. Let's build that system together. The first technique is treating your pipeline itself as code. In the past, build processes were often a collection of scripts on a single server or complex configurations in a web UI. If that server vanished, so did your build knowledge. We don't do that anymore. Now, we define the entire workflow in a file, written in YAML or a similar language, and store it right in our project's Git repository. This file describes every single step: checking out the code, setting up Java, running tests, building the artifact. Because it's code, it gets versioned. You can see who changed the pipeline and why. You can roll back to a previous version if a new step breaks. It becomes a shared, living document of your delivery process. Here’s what a foundational example looks like for a GitHub Actions workflow. This file would live in your repo at .github/workflows/ci.yml. This is a blueprint. When someone pushes code to the main or develop branches, or when a pull request is opened, GitHub Actions reads this file and executes the verify job. It spins up a fresh Ubuntu machine, checks out the code, installs Java 21, and tries to compile the project. The cache step is a small piece of optimization, saving the downloaded libraries between runs to speed things up. The real power begins with the second technique: establishing quality gates. Compiling is the bare minimum. We need to ensure the code is good, not just syntactically correct. A quality gate is a checkpoint that must be passed before the code can proceed. It's like an inspection station on the assembly line. We integrate tools that perform static analysis, security scanning, and enforce test coverage directly into the pipeline. If any of these checks fail, the pipeline stops. It sends a clear message: "This change does not meet our shared standards." This prevents technical debt from piling up in your main branch. Let's expand our pipeline to include these gates. Notice how each check is a distinct step. The continue-on-error: false is crucial. It makes the step a hard stop. The security check using the OWASP Dependency-Check plugin will fail the build if it finds a library with a known critical vulnerability. The final script uses the JaCoCo report to calculate line coverage and fails if it's below 80%. This automates a team agreement. Once our code passes all quality gates, we need to package it for delivery. This brings us to the third technique: building container images as your primary artifact. We ship containers, not JAR files. A container image is a complete, immutable package containing your application, its runtime, and its OS dependencies. It will run the exact same way on your laptop, a test server, and in the cloud. The best way to build these images is inside the pipeline using a multi-stage Docker build. This keeps the build environment consistent and reproducible. Here’s how we add a container build stage to our workflow. First, we define a Dockerfile in our project root: This Dockerfile has two parts. The first stage (builder) uses a full Maven+JDK image to compile the code and produce the JAR file. The second stage (eclipse-temurin:21-jre-alpine) is a tiny image with just the Java runtime. We copy the JAR from the builder stage into this small runtime image. The final image is lean and secure, running as a non-root user. Now, we add a job to our pipeline to build and push this image. This job depends on the verify job passing all its gates. The needs: verify line is critical. This job will only run if the verify job succeeds. We also restrict it to pushes on the main branch. We log in to GitHub's container registry, then use a dedicated action to build and push the image. We tag it with two labels: the unique Git commit SHA (${{ github.sha }}) and latest. The SHA tag is immutable and perfectly identifies this specific build. This leads to the fourth technique: promoting the same immutable artifact through environments. You built one image, identified by its SHA. That exact same binary, byte-for-byte, should be what you test in staging and deploy to production. You avoid the classic "it worked on my machine" problem by testing the actual thing you will ship. We model this with pipeline stages that promote the already-built image. They don't rebuild anything; they just move the artifact forward. The deploy-to-staging job takes the image tagged with the Git SHA and tells the Kubernetes cluster to update the deployment. Kubernetes gracefully replaces the old containers with the new ones. The deploy-to-production job has a manual approval step, requiring a team lead to confirm before proceeding. It then performs the same kubectl set image command, but against the production cluster, using the same image SHA. This is the essence of reliable promotion. The fifth and final technique is about optimizing this entire process for speed. A slow pipeline is a productivity killer. Developers wait for feedback, context switches happen, and deployments are feared. We need it to be fast. We achieve this through caching, parallelization, and thoughtful structuring. We already saw dependency caching. Let's look at running independent tasks in parallel. For instance, integration tests that require a running database can be separate from our unit tests. The strategy.matrix creates four parallel jobs, one for each task in the list. The unit tests, style check, bug finder, and security scan all run at the same time on four different machines, slashing the total feedback time. The integration test job is defined separately and only runs after the unit tests and checkstyle pass, as it's more expensive and requires a database. Putting it all together, this is the modern approach. Your pipeline is version-controlled code. It enforces quality through automated gates. It produces a single, immutable container image. It promotes that exact image through staged environments. And it does all of this as quickly as possible through caching and parallelization. 📘 Checkout my latest ebook for free on my channel! Be sure to like, share, comment, and subscribe to the channel! 101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone. Check out our book Golang Clean Code available on Amazon. Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts! Be sure to check out our creations: Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: name: Java Build and Test on: push: branches: [ 'main', 'develop' ] pull_request: branches: [ 'main' ] jobs: verify: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Java 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Cache Maven dependencies uses: actions/cache@v3 with: path: ~/.m2/repository key: maven-${{ hashFiles('**/pom.xml') }} restore-keys: | maven- - name: Build with Maven run: mvn --batch-mode clean compile Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: name: Java Build and Test on: push: branches: [ 'main', 'develop' ] pull_request: branches: [ 'main' ] jobs: verify: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Java 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Cache Maven dependencies uses: actions/cache@v3 with: path: ~/.m2/repository key: maven-${{ hashFiles('**/pom.xml') }} restore-keys: | maven- - name: Build with Maven run: mvn --batch-mode clean compile CODE_BLOCK: name: Java Build and Test on: push: branches: [ 'main', 'develop' ] pull_request: branches: [ 'main' ] jobs: verify: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Java 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Cache Maven dependencies uses: actions/cache@v3 with: path: ~/.m2/repository key: maven-${{ hashFiles('**/pom.xml') }} restore-keys: | maven- - name: Build with Maven run: mvn --batch-mode clean compile COMMAND_BLOCK: # ... previous steps (checkout, setup java, cache) ... - name: Run unit tests run: mvn --batch-mode test - name: Static Code Analysis with Checkstyle run: mvn --batch-mode checkstyle:check continue-on-error: false - name: Find Security Bugs run: mvn --batch-mode com.github.spotbugs:spotbugs-maven-plugin:check - name: Check for Vulnerable Dependencies run: mvn --batch-mode org.owasp:dependency-check-maven:check - name: Measure Test Coverage run: mvn --batch-mode jacoco:prepare-agent test jacoco:report - name: Enforce Minimum Coverage (80%) run: | COVERAGE=$(awk -F, '{print $4}' target/site/jacoco/jacoco.csv | grep "LINE" | sed 's/%//') if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then echo "Code coverage is ${COVERAGE}%, which is below the 80% minimum." exit 1 fi echo "Code coverage is ${COVERAGE}%, check passed." Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # ... previous steps (checkout, setup java, cache) ... - name: Run unit tests run: mvn --batch-mode test - name: Static Code Analysis with Checkstyle run: mvn --batch-mode checkstyle:check continue-on-error: false - name: Find Security Bugs run: mvn --batch-mode com.github.spotbugs:spotbugs-maven-plugin:check - name: Check for Vulnerable Dependencies run: mvn --batch-mode org.owasp:dependency-check-maven:check - name: Measure Test Coverage run: mvn --batch-mode jacoco:prepare-agent test jacoco:report - name: Enforce Minimum Coverage (80%) run: | COVERAGE=$(awk -F, '{print $4}' target/site/jacoco/jacoco.csv | grep "LINE" | sed 's/%//') if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then echo "Code coverage is ${COVERAGE}%, which is below the 80% minimum." exit 1 fi echo "Code coverage is ${COVERAGE}%, check passed." COMMAND_BLOCK: # ... previous steps (checkout, setup java, cache) ... - name: Run unit tests run: mvn --batch-mode test - name: Static Code Analysis with Checkstyle run: mvn --batch-mode checkstyle:check continue-on-error: false - name: Find Security Bugs run: mvn --batch-mode com.github.spotbugs:spotbugs-maven-plugin:check - name: Check for Vulnerable Dependencies run: mvn --batch-mode org.owasp:dependency-check-maven:check - name: Measure Test Coverage run: mvn --batch-mode jacoco:prepare-agent test jacoco:report - name: Enforce Minimum Coverage (80%) run: | COVERAGE=$(awk -F, '{print $4}' target/site/jacoco/jacoco.csv | grep "LINE" | sed 's/%//') if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then echo "Code coverage is ${COVERAGE}%, which is below the 80% minimum." exit 1 fi echo "Code coverage is ${COVERAGE}%, check passed." COMMAND_BLOCK: # Build stage FROM maven:3.9-eclipse-temurin-21-alpine AS builder WORKDIR /build COPY pom.xml . RUN mvn --batch-mode dependency:go-offline COPY src ./src RUN mvn --batch-mode clean package -DskipTests # Runtime stage FROM eclipse-temurin:21-jre-alpine RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser WORKDIR /app COPY --from=builder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # Build stage FROM maven:3.9-eclipse-temurin-21-alpine AS builder WORKDIR /build COPY pom.xml . RUN mvn --batch-mode dependency:go-offline COPY src ./src RUN mvn --batch-mode clean package -DskipTests # Runtime stage FROM eclipse-temurin:21-jre-alpine RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser WORKDIR /app COPY --from=builder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] COMMAND_BLOCK: # Build stage FROM maven:3.9-eclipse-temurin-21-alpine AS builder WORKDIR /build COPY pom.xml . RUN mvn --batch-mode dependency:go-offline COPY src ./src RUN mvn --batch-mode clean package -DskipTests # Runtime stage FROM eclipse-temurin:21-jre-alpine RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser WORKDIR /app COPY --from=builder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] CODE_BLOCK: build-container: needs: verify runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} ghcr.io/${{ github.repository }}/myapp:latest cache-from: type=gha cache-to: type=gha,mode=max Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: build-container: needs: verify runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} ghcr.io/${{ github.repository }}/myapp:latest cache-from: type=gha cache-to: type=gha,mode=max CODE_BLOCK: build-container: needs: verify runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} ghcr.io/${{ github.repository }}/myapp:latest cache-from: type=gha cache-to: type=gha,mode=max CODE_BLOCK: deploy-to-staging: needs: build-container runs-on: ubuntu-latest environment: staging steps: - name: Deploy to Kubernetes Staging run: | kubectl config use-context my-staging-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-staging kubectl rollout status deployment/myapp-deployment -n myapp-staging deploy-to-production: needs: deploy-to-staging runs-on: ubuntu-latest environment: production if: github.ref == 'refs/heads/main' steps: - name: Approve Production Deployment uses: trstringer/manual-approval@v1 with: secret: ${{ github.token }} approvers: team-leads minimum-approvals: 1 - name: Deploy to Kubernetes Production run: | kubectl config use-context my-production-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-production kubectl rollout status deployment/myapp-deployment -n myapp-production Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: deploy-to-staging: needs: build-container runs-on: ubuntu-latest environment: staging steps: - name: Deploy to Kubernetes Staging run: | kubectl config use-context my-staging-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-staging kubectl rollout status deployment/myapp-deployment -n myapp-staging deploy-to-production: needs: deploy-to-staging runs-on: ubuntu-latest environment: production if: github.ref == 'refs/heads/main' steps: - name: Approve Production Deployment uses: trstringer/manual-approval@v1 with: secret: ${{ github.token }} approvers: team-leads minimum-approvals: 1 - name: Deploy to Kubernetes Production run: | kubectl config use-context my-production-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-production kubectl rollout status deployment/myapp-deployment -n myapp-production CODE_BLOCK: deploy-to-staging: needs: build-container runs-on: ubuntu-latest environment: staging steps: - name: Deploy to Kubernetes Staging run: | kubectl config use-context my-staging-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-staging kubectl rollout status deployment/myapp-deployment -n myapp-staging deploy-to-production: needs: deploy-to-staging runs-on: ubuntu-latest environment: production if: github.ref == 'refs/heads/main' steps: - name: Approve Production Deployment uses: trstringer/manual-approval@v1 with: secret: ${{ github.token }} approvers: team-leads minimum-approvals: 1 - name: Deploy to Kubernetes Production run: | kubectl config use-context my-production-cluster kubectl set image deployment/myapp-deployment \ myapp-container=ghcr.io/${{ github.repository }}/myapp:${{ github.sha }} \ -n myapp-production kubectl rollout status deployment/myapp-deployment -n myapp-production COMMAND_BLOCK: verify: runs-on: ubuntu-latest strategy: matrix: task: [ 'unit-test', 'checkstyle', 'spotbugs', 'dependency-check' ] steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' - name: Cache Maven Repo uses: actions/cache@v3 with: path: ~/.m2 key: maven-${{ hashFiles('**/pom.xml') }} - name: Unit Tests if: matrix.task == 'unit-test' run: mvn --batch-mode test -DskipITs - name: Checkstyle if: matrix.task == 'checkstyle' run: mvn --batch-mode checkstyle:check - name: Integration Tests needs: [ 'unit-test', 'checkstyle' ] # Run after these pass runs-on: ubuntu-latest steps: # ... setup steps ... - name: Start Test Database run: docker-compose -f docker-compose.test.yml up -d - name: Run ITs run: mvn --batch-mode verify -Dit.test Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: verify: runs-on: ubuntu-latest strategy: matrix: task: [ 'unit-test', 'checkstyle', 'spotbugs', 'dependency-check' ] steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' - name: Cache Maven Repo uses: actions/cache@v3 with: path: ~/.m2 key: maven-${{ hashFiles('**/pom.xml') }} - name: Unit Tests if: matrix.task == 'unit-test' run: mvn --batch-mode test -DskipITs - name: Checkstyle if: matrix.task == 'checkstyle' run: mvn --batch-mode checkstyle:check - name: Integration Tests needs: [ 'unit-test', 'checkstyle' ] # Run after these pass runs-on: ubuntu-latest steps: # ... setup steps ... - name: Start Test Database run: docker-compose -f docker-compose.test.yml up -d - name: Run ITs run: mvn --batch-mode verify -Dit.test COMMAND_BLOCK: verify: runs-on: ubuntu-latest strategy: matrix: task: [ 'unit-test', 'checkstyle', 'spotbugs', 'dependency-check' ] steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' - name: Cache Maven Repo uses: actions/cache@v3 with: path: ~/.m2 key: maven-${{ hashFiles('**/pom.xml') }} - name: Unit Tests if: matrix.task == 'unit-test' run: mvn --batch-mode test -DskipITs - name: Checkstyle if: matrix.task == 'checkstyle' run: mvn --batch-mode checkstyle:check - name: Integration Tests needs: [ 'unit-test', 'checkstyle' ] # Run after these pass runs-on: ubuntu-latest steps: # ... setup steps ... - name: Start Test Database run: docker-compose -f docker-compose.test.yml up -d - name: Run ITs run: mvn --batch-mode verify -Dit.test