CI/CD for Java Developers

What's actually your responsibility versus platform/DevOps, why a red pipeline is your code talking to you, backward-compatible database migrations during rollout, and the supply-chain risk of an unpinned Action

← Back to Index

What is CI/CD โ€” and Why Does It Exist?

Before CI/CD, shipping a Java change meant building it on your own machine, and then manually copying the artifact to a server โ€” SCP, FTP, or a shared drive โ€” and restarting the process by hand. Every deploy depended on whoever's laptop built the jar: their exact JDK version, their exact dependency cache, whatever uncommitted local change happened to be sitting in their working directory. "It works on my machine" wasn't a joke; it was the actual deployment process.

// BEFORE โ€” manual build and deploy, different every time
$ mvn package                      # on YOUR laptop, with YOUR local Maven cache
$ scp target/order-service.jar deploy@prod:/opt/app/
$ ssh deploy@prod "systemctl restart order-service"
# Did staging get tested with this exact jar? Unknown.
# Did anyone else's uncommitted change sneak into this build? Unknown.

// AFTER โ€” one pipeline builds it once, the same artifact moves through every stage
// git push triggers: compile โ†’ test โ†’ package โ†’ the SAME jar is
// promoted through staging โ†’ production. No laptop involved, ever.
Three terms people use interchangeably โ€” they're not the same thing
  • Continuous Integration (CI) โ€” every push automatically triggers a build and the full test suite. This is the part almost every team has.
  • Continuous Delivery โ€” every change that passes CI is automatically packaged into a release-ready artifact, but a human still triggers the actual production deploy.
  • Continuous Deployment โ€” every change that passes all checks deploys to production automatically, with no manual gate at all.

CI/CD from a Developer's Seat โ€” What's Actually Your Job

It's easy to hear "CI/CD" in standups and file it under "that's a DevOps thing." In practice, on most teams building services like order-service or customer-service, the split looks like this โ€” and the boundary shifts with team size, but the categories stay useful:

Typically yoursTypically platform/DevOps
The workflow/Jenkinsfile for your own service's build and test stages The runners/agents themselves, and the cluster they execute on
Keeping your tests fast and non-flaky โ€” a flaky test is a bug in your test, not "a pipeline problem" Secrets management infrastructure (Vault, the encrypted-secrets backend itself)
Writing database migrations that don't break the version of your code still running during a rolling deploy (Section 8) The GitOps controller that reconciles manifest changes into the cluster (Section 10)
Reading a failed pipeline's actual error, not just re-running it and hoping Provisioning the environments and networking the pipeline deploys into
A red pipeline is almost always telling you something about your code

The instinct to say "the pipeline is broken" is usually backwards. A failing test, a build that doesn't reproduce locally, a migration that locks a table for ten minutes โ€” these are your code's problems surfacing earlier and cheaper than they would in production. Treating pipeline failures as an infrastructure annoyance instead of direct feedback on your own change is the single biggest mindset gap between developers who trust CI/CD and developers who route around it with -DskipTests.

The CI/CD Pipeline โ€” Stage by Stage

Source → Build → Unit Tests → Static Analysis → Package → Integration Tests → Deploy Staging → Deploy Production
StageWhat runsA failure here usually means
Buildmvn compileA type error or missing dependency โ€” caught before anyone else pulls your branch
Unit Testsmvn testYour change broke a documented behavior, or the test itself is flaky and needs fixing
Static AnalysisSonarQube, Checkstyle โ€” see Code Quality ToolsA real code smell or a quality gate threshold your change pushed past
Packagemvn packageRare if compile/test passed โ€” usually a resource or manifest misconfiguration
Integration TestsTests against a real database/queue, often via TestcontainersYour change works in isolation but breaks against the real schema or a downstream service
Deploy StagingAutomated, no approvalEnvironment configuration drift, not usually your code
Deploy ProductionManual gate (Delivery) or automatic (Deployment)The one place a mistake has real customer impact โ€” this is why the earlier stages exist

GitHub Actions for Java

Basic build and test workflow

# .github/workflows/build.yml
name: Java CI

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

# Least-privilege by default โ€” grant only what this workflow actually needs
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with:
        java-version: '21'
        distribution: 'temurin'
        cache: maven

    - name: Build and test
      run: mvn -B verify

Multi-version testing

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java: [17, 21]
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with:
        java-version: ${{ matrix.java }}
        distribution: 'temurin'
        cache: maven
    - run: mvn -B verify
Pin third-party Actions to a commit SHA, not a floating tag or branch

An Action referenced by @main or even by a version tag like @v4 can change underneath you the moment the maintainer pushes new code to that ref โ€” and several real supply-chain compromises of popular GitHub Actions have worked exactly this way: a widely used third-party action gets compromised, and every workflow referencing it by a mutable ref starts running the attacker's code with access to your repository's secrets on the very next run. Pinning to a specific commit SHA makes the code that actually executes immutable and auditable:

# Fragile โ€” the code that runs can change without your workflow file changing
- uses: some-org/some-action@main
- uses: some-org/some-action@v4

# Pinned โ€” this exact commit's code is what runs, always, until you
# deliberately bump it
- uses: some-org/some-action@8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f # v4.2.1

Official actions maintained directly by GitHub (actions/checkout, actions/setup-java) carry materially lower risk than a random third-party action with a handful of stars โ€” reserve strict SHA-pinning discipline especially for anything outside that trusted set.

Docker build and push

name: Build and Push Image

on:
  push:
    tags: [ 'v*' ]

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with:
        java-version: '21'
        distribution: 'temurin'
        cache: maven

    - name: Build with Maven
      run: mvn -B package -DskipTests

    - name: Log in to registry
      uses: docker/login-action@v3
      with:
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push
      uses: docker/build-push-action@v6
      with:
        context: .
        push: true
        tags: ghcr.io/shop/order-service:${{ github.ref_name }}
pull_request_target is not a drop-in replacement for pull_request

Both `main` example workflows above trigger on pull_request, which runs with a read-only token and no access to repository secrets for PRs from forks โ€” deliberately, because the code being tested came from someone you may not trust. pull_request_target runs with full access to secrets and checks out the base branch by default, which is safe โ€” but if that workflow is modified to also check out and execute the PR's own untrusted code, it hands a stranger's code your secrets. Never combine pull_request_target with a checkout of the incoming PR's head ref.

Jenkins Pipeline

Jenkins is the most widely used self-hosted CI/CD server โ€” maximum flexibility and control, at the cost of you (or a platform team) maintaining the server itself.

// Jenkinsfile in project root
pipeline {
    agent any
    tools {
        maven 'Maven-3.9'
        jdk 'JDK-21'
    }
    stages {
        stage('Build') {
            steps { sh 'mvn clean compile' }
        }
        stage('Unit Tests') {
            steps { sh 'mvn test' }
            post { always { junit 'target/surefire-reports/*.xml' } }
        }
        stage('Package') {
            steps {
                sh 'mvn package -DskipTests'
                archiveArtifacts artifacts: 'target/*.jar'
            }
        }
        stage('Deploy to Production') {
            when { branch 'main' }
            steps {
                input message: 'Deploy to production?'   // manual gate โ€” Continuous Delivery, not Deployment
                sh './deploy.sh production'
            }
        }
    }
}

Parallel stages โ€” independent work runs concurrently

stage('Tests') {
    parallel {
        stage('Unit Tests')        { steps { sh 'mvn test -Dtest=*UnitTest' } }
        stage('Integration Tests') { steps { sh 'mvn verify -Dtest=*IntegrationTest' } }
        stage('Coverage')           { steps { sh 'mvn jacoco:report' } }
    }
}

GitLab CI/CD

# .gitlab-ci.yml
image: maven:3.9-eclipse-temurin-21

cache:
  paths:
    - .m2/repository/

stages:
  - build
  - test
  - package
  - deploy

unit-tests:
  stage: test
  script:
    - mvn test
  artifacts:
    reports:
      junit: target/surefire-reports/TEST-*.xml

package:
  stage: package
  script:
    - mvn package -DskipTests
  artifacts:
    paths:
      - target/*.jar

deploy-production:
  stage: deploy
  script:
    - ./deploy.sh production
  when: manual   # Continuous Delivery gate
  only:
    - main

Maven Configuration for CI/CD

A multi-module reactor (like order-service, customer-service, and shared-domain as separate modules) needs every module's version to move together. Hardcoding the version in every pom.xml means a release requires editing N files; Maven's CI Friendly Versions feature lets CI inject one version into a single placeholder instead.

<!-- pom.xml -->
<version>${revision}</version>

<properties>
    <revision>1.0.0-SNAPSHOT</revision>
</properties>

<build>
    <plugins>
        <!-- Resolves ${revision} to a real value before the artifact is
             installed/deployed โ€” downstream consumers never see the placeholder -->
        <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>flatten-maven-plugin</artifactId>
            <version>1.5.0</version>
            <executions>
                <execution><goals><goal>flatten</goal></goals></execution>
            </executions>
        </plugin>
    </plugins>
</build>
# CI sets the real version at build time โ€” every module gets it consistently
mvn -B -Drevision=1.4.0 clean package

Docker in CI/CD

Multi-stage Dockerfile

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar

# Non-root user โ€” never run the JVM as root inside the container
RUN addgroup -S spring && adduser -S spring -G spring
USER spring

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Docker Compose for integration tests

services:
  order-service:
    build: .
    environment:
      - SPRING_PROFILES_ACTIVE=test
      - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/orders_test
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=orders_test
      - POSTGRES_USER=test
      - POSTGRES_PASSWORD=test

Database Migrations in the Pipeline โ€” the Part That Actually Breaks Production

Rolling and canary deployments (Section 9) mean two versions of your code run against the same database at the same time, for minutes or longer. If a migration in the new version isn't compatible with the old version's queries, the old instances start failing the moment the migration runs โ€” before they're even the ones being replaced.

// V12__rename_customer_full_name.sql โ€” Flyway migration
-- WRONG: renaming a column the old version still SELECTs by name
ALTER TABLE customers RENAME COLUMN full_name TO display_name;
-- The moment this runs, every OLD instance still executing
-- "SELECT full_name FROM customers" starts throwing SQL errors โ€”
-- and during a rolling deploy, old instances are still serving traffic.

-- CORRECT: expand/contract pattern โ€” add the new column first,
-- backfill, deploy code that writes to both, THEN drop the old column
-- in a LATER migration once every instance is on the new version
ALTER TABLE customers ADD COLUMN display_name VARCHAR(255);
UPDATE customers SET display_name = full_name;
-- full_name is dropped only in a future release, after this one is fully rolled out
# Running Flyway as an explicit pipeline stage โ€” a separate step from
# the application deploy, so a failed migration blocks the rollout
# instead of half-applying during app startup
mvn flyway:migrate -Dflyway.url=$DB_URL -Dflyway.user=$DB_USER
The expand/contract pattern is a developer decision, not a DBA policy

Every additive-only, backward-compatible migration in this pattern is something you write when you design the change โ€” the pipeline just runs it. Getting this wrong isn't a CI/CD failure to blame on tooling; it's the same category of mistake as the cascade-on-delete trap covered in Entity Relationships โ€” a correct-looking change with a consequence that only shows up under a specific runtime condition the author didn't consider.

Deployment Strategies โ€” and What Each One Requires From Your Code

Blue-Green

Blue (current)  ← production traffic
Green (new)     ← deploy and test the new version here first

// Switch traffic once Green is verified. Rollback = switch back to Blue.

Requires: the new version must be able to run health checks (Spring Boot Actuator's /actuator/health) before receiving any real traffic โ€” the switch is only safe if "healthy" actually means healthy.

Canary

v1.0 (current) ← 95% of traffic
v1.1 (canary)  ← 5% of traffic, monitored, gradually increased

Requires: your metrics and error rates must be observable per version โ€” a canary you can't measure isn't a canary, it's just an unmonitored partial rollout.

Rolling

Instance 1: v1.0 -> v1.1 (updating)
Instance 2: v1.0
Instance 3: v1.0
// One instance at a time, until all are updated

Requires: the exact backward-compatibility guarantee from Section 8 โ€” old and new instances serve real traffic against the same schema simultaneously throughout the rollout.

Feature flags decouple "deployed" from "visible to users"

All three strategies get considerably safer when risky logic ships behind a feature flag rather than a straight code path โ€” the code deploys and runs everywhere immediately, but the new behavior only activates for a controlled subset, independent of the rollout mechanism itself. This is also what makes trunk-based development (see Version Control) practical: you can merge unfinished work to main safely as long as it's flagged off.

GitOps โ€” the Modern "Deploy" Step

Increasingly, the final deploy step isn't your pipeline running kubectl apply directly against a cluster. Instead, a controller running inside the cluster (ArgoCD, Flux) continuously watches a Git repository of Kubernetes manifests and reconciles the cluster to match whatever is committed there.

// What actually changes for you:
// Your CI pipeline builds the image and pushes it to a registry, then
// opens a PR (or commits directly) bumping the image tag in a manifests
// repo โ€” it never touches the cluster itself.

// manifests-repo/order-service/deployment.yaml
image: ghcr.io/shop/order-service:1.4.0   # โ† this line is what your pipeline updates

// ArgoCD notices the commit, diffs it against the live cluster state,
// and applies the change itself โ€” with a full audit trail of every
// change as ordinary Git history.

For you as a developer, this mostly means: the artifact you build and the state of the cluster are connected entirely through Git commits, so "what's running in production right now" is always answerable by reading a file in a repo, not by asking someone with cluster access.

Security Scanning in the Pipeline

name: Security Scan

on: [push, pull_request]

permissions:
  contents: read

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: OWASP Dependency Check
      uses: dependency-check/Dependency-Check_Action@<pinned-commit-sha> # pin to a specific release commit, not @main
      with:
        project: 'order-service'
        path: '.'
        format: 'HTML'

    - uses: actions/upload-artifact@v4
      with:
        name: dependency-check-report
        path: reports/

This scans your dependencies for known CVEs. It does not protect you from Section 3's supply-chain risk โ€” an Action itself being malicious is a different threat than a vulnerable library, and needs the SHA-pinning discipline covered there.

Best Practices and Common Pitfalls

โœ… Do

  • Fail fast โ€” run compile and lint before the slower test suites, not after
  • Cache Maven/Gradle dependencies to keep pipeline feedback under ~10 minutes
  • Build the artifact once and promote that exact artifact through every environment โ€” never rebuild per environment
  • Pin third-party GitHub Actions to a commit SHA; declare least-privilege permissions explicitly
  • Write migrations as additive/expand-contract so old and new code can run against the same schema during rollout
  • Treat a red pipeline as your code telling you something, not an infrastructure inconvenience to route around

โŒ Don't

  • Don't use -DskipTests as a habit to get a red pipeline green โ€” it hides the signal, it doesn't fix it
  • Don't reference third-party Actions by @main or a floating tag โ€” that's the exact vector of real supply-chain compromises
  • Don't combine pull_request_target with checking out the incoming PR's own code โ€” it hands a stranger's code your secrets
  • Don't rename or drop a column in the same migration that removes the old code path โ€” use expand/contract across two releases
  • Don't treat Canary/Blue-Green as purely an ops concern โ€” they only work if your code exposes real health checks and tolerates two versions running concurrently

Interview Questions

๐ŸŽ“ Junior level

Q: What's the difference between Continuous Delivery and Continuous Deployment?
Both automatically build, test, and package every change that passes CI into a release-ready artifact. Continuous Delivery stops there and requires a human to trigger the actual production deploy. Continuous Deployment removes that manual gate โ€” a passing pipeline deploys to production automatically.

Q: Why should a pipeline build the artifact once and reuse it, instead of rebuilding for each environment?
If staging and production are built from separate compile steps, you're no longer testing the actual bytes that will run in production โ€” a different dependency resolution, a different compiler flag, or a flaky build could silently produce a different artifact. Building once and promoting that exact jar through every environment guarantees what you tested in staging is byte-for-byte what reaches production.

Q: Why is a rolling deployment risky if a database migration in the same release renames a column?
During a rolling deployment, old and new instances of the application serve real traffic against the same database simultaneously. If the migration renames a column the old version's queries still reference by its old name, every remaining old instance starts failing immediately โ€” before the rollout has even finished replacing them.

๐Ÿ”ฅ Senior level

Q: A workflow references a third-party GitHub Action via @v3. Explain precisely why this is a supply-chain risk even though the version looks pinned, and what actually mitigates it.
A version tag like v3 is a mutable Git ref โ€” the maintainer (or an attacker who compromises the maintainer's account) can force-push a different commit to that same tag at any time, and every workflow referencing @v3 will silently start executing that new code on its very next run, with whatever secrets and repository access that workflow already has. This is exactly the mechanism behind real supply-chain compromises of popular Actions in recent years. The only mitigation that actually closes this gap is pinning to an immutable commit SHA โ€” a SHA cannot be reassigned to different content, so the code that executes is guaranteed identical to what you last reviewed, until you deliberately change the pin yourself.

Q: Your team wants to migrate a NOT NULL column with no default on a large orders table, deployed via rolling update. Walk through why a single-migration approach breaks, and design the safe sequence.
A single migration that adds the column as NOT NULL locks the entire table while it back-fills a value for every existing row โ€” on a large table this can hold a write lock for minutes, during which the old application instances, still receiving traffic, will have their writes blocked or time out. The safe sequence follows expand/contract across at least two releases: first add the column as nullable with no default (a fast, metadata-only operation on most engines); ship application code that writes the new column going forward while tolerating nulls on read; run an online, batched backfill of historical rows outside of peak traffic; only once every instance is confirmed on the version that populates the column, add the NOT NULL constraint in a later migration, and only then remove any fallback logic that tolerated nulls. Each step is independently safe to roll out and roll back; the single-migration version is not.

Q: Why does adopting GitOps change what "who deployed this" means, and what real problem does that solve?
In a traditional pipeline, the deploy step is a script or job that authenticates directly against the cluster and pushes a change โ€” the audit trail for "what's actually running" lives scattered across CI job logs and whoever had cluster credentials at the time, and the live cluster state can drift from what any manifest says it should be if someone applies a manual change out-of-band. Under GitOps, the cluster's desired state is defined entirely by what's committed in a manifests repository, and a controller continuously reconciles the live cluster to match it โ€” including reverting manual out-of-band changes back to whatever Git says. "What's running in production" becomes answerable by reading Git history, and "who deployed this" becomes "who merged this commit," which is the same audit trail your team already trusts for code review.