Skip to content
All posts
DevOps#devops#jenkins#ci-cd#linux#automation#learning#java

Stop Java Version Drift Before Production

Learn why Java builds succeed in Jenkins but fail on servers, how class-file versions expose runtime drift, and how to enforce one Java baseline across CI/CD.

Priyanshu Maurya

DevOps & Cloud Engineer

30 Jul ’26 9 min
ShareLink copied

A Java application can compile perfectly in Jenkins, pass its tests, produce a valid WAR file, deploy successfully to Tomcat, and still crash the moment the application server tries to load a class.

One of the clearest examples looks like this:

java.lang.UnsupportedClassVersionError:
SomeClass has been compiled by a more recent version
of the Java Runtime

class file version 65.0

this Java Runtime only recognizes class file versions
up to 61.0

This is not a Tomcat problem. It is not usually a corrupted WAR. Rebuilding the application repeatedly will not magically fix it either, despite humanity's long tradition of rerunning failed commands and hoping the computer develops empathy.

The error means something precise:

Your code was compiled for a newer Java version than the runtime executing it supports.

In this example:

Class-file versionJava version
55Java 11
61Java 17
65Java 21

The class was compiled using Java 21-compatible bytecode, while the application server was running Java 17.

That is Java version drift, and CI/CD systems make it surprisingly easy to create.


Why Java version drift happens

A Java deployment involves more than one Java installation.

A typical delivery path might look like:

Developer laptop
      |
      v
Git repository
      |
      v
Jenkins agent
      |
      v
Maven
      |
      v
WAR artifact
      |
      v
EC2 / VM
      |
      v
Tomcat
      |
      v
JVM

Every stage can potentially use a different Java version.

For example:

Developer       Java 17
Jenkins agent   Java 21
Maven compiler  Java 21
Tomcat server   Java 17

Jenkins happily builds the application because its environment supports Java 21.

The WAR is valid.

The deployment succeeds because copying a WAR file does not execute every class inside it.

Then Tomcat starts loading the application's classes using Java 17.

Java 17 encounters bytecode created for Java 21 and refuses to load it.

The real problem is therefore not merely:

"Tomcat failed."

The system-level problem is:

The build environment and runtime environment disagree about the Java contract.

That distinction matters because fixing symptoms without understanding the contract creates recurring failures.

Start debugging from the error, not from guesses

UnsupportedClassVersionError already gives you valuable evidence.

Suppose you see:

class file version 65.0
runtime only recognizes versions up to 61.0

You can immediately infer:

Compiled bytecode -> Java 21
Running JVM       -> Java 17

Now verify each environment.

On the application server:

java -version

You might see:

openjdk version "17.0.x"

Check the compiler:

javac -version

Then inspect Tomcat's actual process:

ps -ef | grep tomcat

or:

ps -ef | grep java

Find the Java executable being used:

readlink -f /proc/$(pgrep -f 'org.apache.catalina.startup.Bootstrap' | head -1)/exe

This matters because:

java -version

only tells you which Java your current shell finds through PATH.

Tomcat may be running another JVM entirely.

Check what Jenkins is actually using

Do not assume Jenkins uses the same JDK as your SSH session.

Add temporary diagnostic steps:

stage('Verify Java') {
    steps {
        sh '''
            java -version
            javac -version
            mvn -version
            echo "JAVA_HOME=$JAVA_HOME"
            which java
            readlink -f "$(which java)"
        '''
    }
}

mvn -version is particularly useful because it reports the Java runtime Maven itself is using.

For example:

Apache Maven 3.9.x
Java version: 21
Java home: /opt/jdk-21

If production runs Java 17, you have found the mismatch.

Do the same investigation on the deployment target:

java -version
echo "$JAVA_HOME"
which java
readlink -f "$(which java)"

You are comparing environments, not merely collecting commands.

That is the debugging skill worth remembering.


JAVA_HOME and PATH are not the same thing

A common source of confusion is assuming that setting JAVA_HOME guarantees every process uses that JVM.

It does not.

Consider:

export JAVA_HOME=/usr/lib/jvm/java-17-openjdk

while:

which java

returns:

/usr/bin/java

The shell may still resolve Java through PATH.

A safer configuration is:

export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
export PATH="$JAVA_HOME/bin:$PATH"

Then verify:

java -version
javac -version

System services introduce another complication.

Tomcat launched by systemd does not necessarily inherit environment variables from your interactive shell.

Inspect the service:

systemctl cat tomcat

Then inspect its environment:

systemctl show tomcat --property=Environment

Depending on the installation, Java configuration may exist in a service override or application-specific environment file.

This is why:

java -version

working correctly after SSH login does not prove Tomcat is using the same JVM.

Fix the contract, not just the server

There are two legitimate solutions to a Java version mismatch.

Upgrade the runtime

If the application intentionally requires Java 21, upgrade the production runtime to Java 21.

Conceptually:

Build:    Java 21
Runtime:  Java 21

This is appropriate when the application depends on Java 21 APIs, language features, frameworks, or libraries.

But upgrading production blindly has consequences.

You must verify:

  • Tomcat supports the chosen Java version.
  • Application dependencies support it.
  • Monitoring agents support it.
  • Startup scripts reference the correct JDK.
  • Memory and JVM options remain valid.
  • Other applications sharing the host are compatible.

Production infrastructure is not the ideal place for spontaneous archaeological discoveries.

Compile for the older runtime

If production must remain on Java 17, compile the application for Java 17.

For Maven, configure the compiler baseline explicitly.

<properties>
    <maven.compiler.release>17</maven.compiler.release>
</properties>

Using release is generally preferable to configuring only source and target because it also constrains compilation against the selected Java platform API.

Then build:

mvn clean verify

Your contract becomes:

Compiler JDK:   Java 21 possible
Target release: Java 17
Runtime:        Java 17

However, this only works if your code and dependencies are compatible with Java 17.

You cannot compile Java 21-specific application code for Java 17 merely by changing a number in pom.xml.

Enforce the Java version in Jenkins

Relying on whatever Java happens to exist on a Jenkins worker is fragile.

Define the expected toolchain explicitly.

A Declarative Pipeline can use configured Jenkins tools:

pipeline {
    agent any

    tools {
        jdk 'JDK17'
        maven 'MAVEN3.9'
    }

    stages {
        stage('Verify Toolchain') {
            steps {
                sh '''
                    set -eu

                    java -version
                    javac -version
                    mvn -version
                '''
            }
        }

        stage('Build') {
            steps {
                sh 'mvn -B clean verify'
            }
        }
    }
}

Now the expected JDK becomes part of pipeline configuration instead of an undocumented property of whichever worker Jenkins selects.

Still, verify it.

Configuration without verification is merely optimism with YAML or Groovy around it.


Fail early when versions do not match

A stronger pipeline validates its assumptions before spending time compiling and deploying.

For example:

JAVA_MAJOR=$(java -version 2>&1 |
    awk -F '[\".]' '/version/ {print $2}')

if [ "$JAVA_MAJOR" != "17" ]; then
    echo "Expected Java 17, found Java $JAVA_MAJOR"
    exit 1
fi

Now a misconfigured Jenkins agent fails immediately.

You can perform similar checks during deployment:

EXPECTED_JAVA=17

ACTUAL_JAVA=$(java -version 2>&1 |
    awk -F '[\".]' '/version/ {print $2}')

test "$ACTUAL_JAVA" = "$EXPECTED_JAVA" || {
    echo "Runtime Java mismatch"
    exit 1
}

The principle is simple:

Turn hidden environmental assumptions into executable checks.

That is one of the most useful habits in DevOps.

Inspect the artifact itself

Sometimes the Jenkins environment has already changed by the time you investigate an old failed build.

You can inspect compiled bytecode directly.

First locate a class:

jar tf target/application.war | head

Extract the relevant class if necessary, then inspect it using:

javap -verbose SomeClass.class | grep "major version"

You may get:

major version: 65

That proves the artifact itself contains Java 21 bytecode.

This is much stronger evidence than saying:

"I think Jenkins was probably using Java 21."

Good incident debugging moves from assumptions toward artifacts and observable state.


Containers can eliminate part of the problem

Dedicated build containers can make Java environments more deterministic.

Instead of relying on JDKs manually installed on Jenkins agents:

Jenkins worker
  └── random locally installed Java

build inside a controlled image:

Jenkins
  |
  v
Java 17 + Maven build image
  |
  v
Artifact

For example, a CI environment can standardize on a versioned JDK/Maven container image.

The benefit is reproducibility.

The build environment becomes something that can be versioned and reviewed rather than something an administrator configured six months ago and subsequently forgot existed.

Containers do not automatically solve runtime compatibility, though.

You still need the contract:

Build target = production runtime

A perfectly reproducible Java 21 build still fails predictably on Java 17. Automation is wonderfully efficient at reproducing bad assumptions.

Treat Java compatibility as pipeline policy

The mature solution is not documenting:

"Remember to install Java 17."

Documentation helps, but enforcement is better.

Your CI/CD system should define and verify:

Required JDK
      |
      v
Compiler release
      |
      v
Dependency compatibility
      |
      v
Runtime JDK
      |
      v
Application server compatibility

For a Java 17 application, that might mean:

Jenkins JDK        17
Maven release      17
Application target 17
Tomcat JVM         17

Or perhaps:

Jenkins JDK        21
Maven release      17
Application target 17
Tomcat JVM         17

Both can be valid.

The important part is that the relationship is intentional.

A practical debugging checklist

When UnsupportedClassVersionError appears, check in this order:

  1. Read both class-file version numbers from the exception.
  2. Determine which Java versions they represent.
  3. Run java -version on the runtime server.
  4. Check the JVM actually running Tomcat or the service.
  5. Run mvn -version inside Jenkins.
  6. Check JAVA_HOME and PATH.
  7. Inspect Maven compiler configuration.
  8. Inspect the artifact's class major version if necessary.
  9. Decide whether the runtime should be upgraded or the target release lowered.
  10. Add automated version checks so the mismatch cannot silently return.

Do not start by reinstalling Tomcat, rebuilding the server, changing random environment variables, or repeatedly rerunning Maven.

Those actions modify evidence before you understand the failure.

The production lesson

Java version errors are useful because they expose a broader DevOps problem: environment drift.

The same class of failure appears with Python versions, Node.js versions, Linux packages, Terraform providers, container base images, database engines, and Kubernetes APIs.

A production-ready pipeline therefore does more than automate commands.

It establishes contracts between environments and verifies them.

For Java workloads, know exactly:

what JDK builds the application
what release the compiler targets
what JVM executes the application

If those three facts are explicit and enforced, UnsupportedClassVersionError becomes rare and easy to diagnose.

If they are unknown, your CI/CD pipeline is not reproducible. It merely happens to work today.

The memorable rule is simple: never let production discover your Java version requirements for you.

Last updated Sep 7, 2026

ShareLink copied

Enjoyed this? I write about what I'm learning.

DevOps, cloud, Linux, and automation - one honest post at a time. Follow along on my learning journey.