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 version | Java version |
|---|---|
| 55 | Java 11 |
| 61 | Java 17 |
| 65 | Java 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:
- Read both class-file version numbers from the exception.
- Determine which Java versions they represent.
- Run
java -versionon the runtime server. - Check the JVM actually running Tomcat or the service.
- Run
mvn -versioninside Jenkins. - Check
JAVA_HOMEandPATH. - Inspect Maven compiler configuration.
- Inspect the artifact's class major version if necessary.
- Decide whether the runtime should be upgraded or the target release lowered.
- 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
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.