Reading and Migrating Old Jenkins Setups: Freestyle to Pipeline, Blue Ocean to Pipeline Graph View, Java 17 to 21
You read an old Jenkins with an inventory first, then migrate step by step: Java 21 first, then the Jenkins version, then jobs. The real message 2.580.2 prints when refusing to start on Java 17, the Java timeline for controllers and agents, the Jenkinsfile equivalent of a Freestyle config.xml, Pipeline Graph View instead of the deprecated Blue Ocean, JNLP to WebSocket, and a quick look at GitHub Actions Importer.
Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), Eclipse Temurin 17.0.21 and 21.0.13, the Pipeline Graph View plugin 1053 and Docker 29. GitHub Actions Importer was not run.
Guess First
The Jenkins server you inherited runs on Java 17, and you want to upgrade Jenkins to 2.580.2. You update the package and restart the service, but you do not touch Java. What happens: does Jenkins start with some plugins throwing errors, does it run slowly, or does it not start at all? If it does not start, how clearly does the log say why?
Reading an Old Jenkins: the Inventory
When you inherit a Jenkins that has been running for years, the first job is not to change something but to see what is there. The questions to ask: which Jenkins and which Java, how many plugins, how many jobs and of which type, how agents connect, whether the controller has executors, where the credentials are. Most of the answers come from a single script console script:
def j = Jenkins.get()
println "Jenkins ${j.version}, Java ${System.getProperty('java.version')}"
println "Controller executors: ${j.numExecutors}"
println "Plugins: ${j.pluginManager.plugins.size()}"
j.getAllItems(hudson.model.Job).groupBy { it.class.simpleName }.each { type, jobs -> println "${type}: ${jobs.size()}" }
j.nodes.each { n -> println "Agent ${n.nodeName}: ${n.launcher.class.simpleName}, labels=[${n.labelString}]" }
Its output on this series' controller:
Jenkins 2.580.2, Java 21.0.13
Controller executors: 0
Plugins: 96
WorkflowJob: 26
FreeStyleProject: 1
Agent docker-agent-1: JNLPLauncher, labels=[linux docker]
Agent ssh-agent-1: SSHLauncher, labels=[linux ssh]
In a real old installation this table looks very different: hundreds of FreeStyleProjects, 2 or 4 executors on the controller, more than 200 plugins. The WorkflowJob count tells you how far the move to Pipeline has gone, the plugin count how painful the upgrade will be. The inbound agent's class name still being JNLPLauncher is an interesting detail too; we will come back to it below. Since the script console can do anything, run this script in its read-only form and with an authorized user.
The Java Timeline and Upgrading
The answer to the opening question: Jenkins does not start. I ran the 2.580.2 war file with Java 17:
openjdk version "17.0.21" 2026-10-20
Running with Java 17 from /opt/java/openjdk, which is older than the minimum required version (Java 21).
Supported Java versions are: [21, 25]
See https://jenkins.io/redirect/java-support/ for more information.
The message is clear, but you do not want to see it in production on an upgrade night. The relevant lines from the Jenkins Java support policy:
| Java | First LTS | Last LTS / planned end |
|---|---|---|
| 17 | 2.346.1 (June 2022) | 2.541.3 (March 2026), no longer supported |
| 21 | 2.479.1 (October 2024) | Planned end of support: September 2027 |
| 25 | 2.541.1 (January 2026) | Planned end of support: September 2029 |
The 2.555.1 upgrade guide stresses one detail in particular: the Java 21 or 25 requirement applies not only to the controller but also to agent JVMs (Java Virtual Machine). If a project builds with Java 17 on an agent, the agent process must still run with Java 21 or 25, and the build uses a separate JDK (Java Development Kit) (for example in a Docker agent's image). That is why the upgrade order is:
- Take inventory: the Java version of the controller and of every agent (the Versions Node Monitors plugin helps with agents).
- First upgrade Java on your current Jenkins version: on an LTS that supports Java 21 (2.479.1 and later), restart the controller and agents with Java 21 and run that way for a few days.
- Then upgrade Jenkins; do not change two things at once, so you know the cause when something breaks.
- If you use Docker images, check the JDK at the end of the tag: the Java 17 controller images were removed with 2.555.1; move to the
-jdk21or-jdk25tags.
Reading Freestyle Jobs
The config.xml of a typical Freestyle job in an old Jenkins looks like this; I created this job for the post:
<?xml version='1.1' encoding='UTF-8'?>
<project>
<description>Legacy Bookstore web packaging job</description>
<assignedNode>linux</assignedNode>
<canRoam>false</canRoam>
<scm class="hudson.plugins.git.GitSCM">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>http://jenkwork-gitea:3000/bookstore/bookstore.git</url>
<credentialsId>gitea-ci</credentialsId>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/main</name>
</hudson.plugins.git.BranchSpec>
</branches>
</scm>
<triggers>
<hudson.triggers.SCMTrigger>
<spec>H/5 * * * *</spec>
</hudson.triggers.SCMTrigger>
</triggers>
<builders>
<hudson.tasks.Shell>
<command>echo "Packaging web sources for build $BUILD_NUMBER"
tar -czf web-src-$BUILD_NUMBER.tgz bookstore-web/src
ls -l web-src-*.tgz</command>
</hudson.tasks.Shell>
</builders>
<publishers>
<hudson.tasks.ArtifactArchiver>
<artifacts>web-src-*.tgz</artifacts>
<fingerprint>true</fingerprint>
</hudson.tasks.ArtifactArchiver>
</publishers>
</project>
Lines from the build log:
Started by user admin
Running as SYSTEM
Building remotely on docker-agent-1 (linux docker) in workspace /home/jenkins/agent/workspace/legacy-web-package
Checking out Revision 2dd9e11bc7e6d35170bc3ba6cc3f386519fffcff (refs/remotes/origin/main)
[legacy-web-package] $ /bin/sh -xe /tmp/jenkins14282019625289931251.sh
+ echo Packaging web sources for build 1
Packaging web sources for build 1
+ tar -czf web-src-1.tgz bookstore-web/src
+ ls -l web-src-1.tgz
-rw-r--r-- 1 jenkins jenkins 1722 Nov 8 05:03 web-src-1.tgz
Archiving artifacts
Recording fingerprints
Finished: SUCCESS
Note the Running as SYSTEM line: unless an additional authorization plugin is configured, builds run with Jenkins's own identity. The real problem with Freestyle, though, is that the recipe lives on the controller's disk, not in the repository: nobody knows who changed what and when, there is no review, and it does not change along with the branch.
From Freestyle to Pipeline
The migration is usually mechanical: every part of Freestyle has a counterpart in Pipeline.
| Freestyle (config.xml) | Declarative Pipeline |
|---|---|
assignedNode | agent { label 'linux' } |
scm (GitSCM) | automatic checkout in multibranch, the git step in a single job |
SCMTrigger | triggers { pollSCM(...) }, preferably a webhook |
hudson.tasks.Shell | sh '''...''' |
ArtifactArchiver | post { success { archiveArtifacts ... } } |
| Build discarder setting | options { buildDiscarder(...) } |
The Pipeline version of the same job. Since this experiment ran as a single pipeline job, I did the checkout with the git step; in a Jenkinsfile in the repository, multibranch does it by itself:
pipeline {
agent { label 'linux' }
triggers {
pollSCM('H/5 * * * *')
}
options {
buildDiscarder(logRotator(numToKeepStr: '20'))
}
stages {
stage('Checkout') {
steps {
git url: 'http://jenkwork-gitea:3000/bookstore/bookstore.git', branch: 'main', credentialsId: 'gitea-ci'
}
}
stage('Package') {
steps {
sh '''
echo "Packaging web sources for build $BUILD_NUMBER"
tar -czf web-src-$BUILD_NUMBER.tgz bookstore-web/src
ls -l web-src-*.tgz
'''
}
}
}
post {
success {
archiveArtifacts artifacts: 'web-src-*.tgz', fingerprint: true
}
}
}
+ echo Packaging web sources for build 1
Packaging web sources for build 1
+ tar -czf web-src-1.tgz bookstore-web/src
+ ls -l web-src-1.tgz
-rw-r--r-- 1 jenkins jenkins 1724 Nov 8 05:04 web-src-1.tgz
Archiving artifacts
Recording fingerprints
Finished: SUCCESS
The same output, but the recipe is now a file. When migrating, remember that you are moving a pattern, not a job: if ten Freestyle jobs do the same thing with small differences, turn them not into ten Jenkinsfiles but into one multibranch job and, if needed, a library step like the one in the tenth post. And a suggested order: first the jobs that run most and change most often; migrating a job that runs once a year is often more expensive than deleting it.
From Blue Ocean to Pipeline Graph View
For years Blue Ocean was installed as the "modern UI (User Interface)" for Pipeline. According to the Jenkins documentation, Blue Ocean has been deprecated since July 2026; new development has stopped, and it will receive only selective updates for significant security issues or defects. The replacements the documentation recommends are the actively maintained Pipeline Graph View plugin and Pipeline: Stage View. We have used Pipeline Graph View from the start of this series; the graph on the build page and the stages view (/job/bookstore/job/main/5/stages/) where we read the parallel branch durations in the eighth post come from it.
The migration is a plugin removal job. Blue Ocean comes as a plugin family and pulls in its own dependencies; removing blueocean from plugins.txt and adding Pipeline Graph View is a one-line PR (Pull Request) on a controller managed as code. On an old controller managed by hand, first check who uses Blue Ocean links (/blue/...): notification e-mails, chat messages and wiki pages may be full of them.
From JNLP to Inbound and WebSocket
Old documents and scripts mention "JNLP (Java Network Launch Protocol) agents". The name is left over from the days when an agent was started from the browser with Java Web Start; today the same thing is called an inbound agent. Traces of the name are still around: the JNLPLauncher class in the inventory and the old option of agent.jar:
-jnlpUrl URL : instead of talking to the controller
via stdin/stdout, emulate a JNLP
client by making a TCP connection to
the controller. Connection parameters
In old installations inbound agents connect this way, over TCP (Transmission Control Protocol) port 50000. Today's way is the -url, -name, -secret and -webSocket options: the connection goes through the controller's normal HTTP (Hypertext Transfer Protocol) port, with no extra port to open and no special reverse proxy configuration. Our agents in the fourth post (JENKINS_WEB_SOCKET=true) connected this way. When migrating, switch the agents to WebSocket one by one and close port 50000 last.
From Jenkins to GitHub Actions?
Sometimes the right migration is not within Jenkins but out of it. If your code is already on GitHub and the "why Jenkins" reasons from the first post no longer apply, GitHub Actions Importer is an official tool that helps: the gh actions-importer CLI (Command Line Interface) extension. The audit command scans the jobs in Jenkins and reports how much can be converted automatically, dry-run converts a single pipeline into workflow YAML (YAML Ain't Markup Language), and migrate opens the conversion as a pull request. Scripted pipelines, unknown plugins and secrets are the parts that must be moved by hand. I did not run the tool in this post; we will cover the GitHub Actions side in the upcoming GitHub Actions series.
In Production
Migrate in small steps and keep every step reversible: Java first, then the Jenkins version, then plugin cleanup, then jobs. Before each step, a backup and a restore test as in the eleventh post.
Run old and new jobs side by side for a while, compare their outputs (the same artifact, the same number of tests), and disable the old job only after that. Leaving it "disabled" for a while before deleting it reveals a forgotten dependency.
Find an owner for every migrated job. A job nobody claims is a candidate for deletion, not migration.
Common Pitfalls
Upgrading Java and Jenkins on the same night. When something breaks, you cannot tell which one caused it.
Forgetting the agents' Java. The controller starts on Java 21, but agents on Java 17 cannot connect.
Copying Freestyle jobs one to one. Turning ten similar jobs into ten similar Jenkinsfiles only moves the repetition somewhere else.
Forgetting Blue Ocean links. When the plugin is removed, old links return 404; update your notification templates.
Treating migration as a one-off. If the setup from the eleventh post (JCasC, pinned plugins, regular upgrades) is not in place when the migration is done, you will be reading this same post again in a few years.
A Small Experiment
★☆☆ Run the inventory script on your own Jenkins (with an authorized user) and write job types, the plugin count and the agents' connection methods into a table.
★★☆ Convert a Freestyle job into a Jenkinsfile using the table above, run both jobs side by side and compare the fingerprints of their artifacts.
★★★ Write a plan to move your controller and one agent to Java 21: in which order, with which backup, how you verify, and how you roll back if something breaks. Apply the plan on a test controller.
The series ends here. In twelve posts we set up a controller from scratch in Docker, wrote pipelines with a Jenkinsfile, configured agents, credentials and multibranch, built and tested Bookstore's .NET API (Application Programming Interface) and Angular app, deployed to two environments with an approval, moved common code into a library, and managed and backed up the controller itself as code. Let your next step be taking one of these posts and applying it to your own team's pipeline; if you are curious about the tool-independent side of CI (Continuous Integration) and CD (Continuous Delivery), the upcoming CI/CD Fundamentals series is exactly for that.