Jenkins Course
Performance Tuning
A Jenkins that handles 20 builds a day feels fast. The same Jenkins handling 200 builds a day without tuning will slow to a crawl, eat all available memory, and start failing builds mid-run. This lesson is about staying ahead of that wall.
This lesson covers
JVM heap tuning → Build log retention → Workspace cleanup → Fingerprint cleanup → Plugin performance → Disk I/O → The before/after comparison for a tuned Jenkins
Jenkins performance problems almost always have the same root causes: too much data retained (build logs, artifacts, fingerprints), too little memory allocated to the JVM, and too many plugins doing work on every request. Fix these three things and most performance problems disappear.
The Analogy
Tuning Jenkins is like maintaining a kitchen. A kitchen that's never cleaned up runs out of counter space, the fridge fills with forgotten leftovers, and cooking takes twice as long because you're working around the clutter. Regular cleanup — throwing out old build logs, clearing workspaces, removing unused plugins — keeps Jenkins fast the same way clearing counter space lets a chef work efficiently.
Before and After Tuning
Untuned Jenkins — The Symptoms
- Jenkins UI takes 8–15 seconds to load
- OutOfMemoryError in jenkins.log
- Disk at 95% — builds failing with "no space"
- Job list page times out with hundreds of jobs
- Builds queue for 20 minutes before starting
- Garbage collector runs constantly — CPU pegged at 90%
Tuned Jenkins — The Results
- UI loads in under 2 seconds
- JVM heap stays below 70% under peak load
- Disk stays below 60% with automatic cleanup
- Job list loads instantly with folder organisation
- Builds start within seconds of triggering
- GC pauses are brief and infrequent
JVM Heap Tuning — The Most Impactful Change
Jenkins is a Java application. Its memory footprint is governed by the JVM (Java Virtual Machine) heap settings. The default heap size is too small for any Jenkins handling real production load. Getting the heap size right is the single most impactful performance change you can make.
Recommended JVM heap by workload
Small team
Under 20 jobs, under 50 builds/day
2–4 GB
Medium team
50–200 jobs, 50–200 builds/day
4–8 GB
Large org
200+ jobs, 200+ builds/day
8–16 GB
The scenario:
You're a platform engineer whose Jenkins is showing OutOfMemoryErrors in the logs and the UI has become sluggish during peak build times (9–11 AM). The server has 16 GB of RAM. Jenkins was installed with default JVM settings — which allocate only 256 MB heap. You need to tune the heap, enable GC logging, and verify the change took effect.
New terms in this code:
- JAVA_OPTS / JENKINS_JAVA_OPTS — environment variable that passes JVM options to the Jenkins process. Set in
/etc/default/jenkinson Debian/Ubuntu or/etc/sysconfig/jenkinson RHEL/CentOS. - -Xms — the initial heap size the JVM allocates at startup. Setting this equal to
-Xmxprevents heap resizing overhead during load spikes. - -Xmx — the maximum heap size. The JVM will never use more memory than this for its heap. Set to 50–60% of total available RAM — leave the rest for the OS, plugin processes, and build agent processes.
- -XX:+UseG1GC — enables the G1 Garbage Collector, which is better than the default for large heaps and long-running JVM processes like Jenkins. It reduces pause times and handles heap fragmentation more efficiently.
- -Xlog:gc — enables GC logging. Writes garbage collection events to a file so you can see how often and how long GC pauses are happening. Essential for diagnosing memory pressure.
# On Ubuntu/Debian: edit /etc/default/jenkins
# On RHEL/CentOS: edit /etc/sysconfig/jenkins
# Open the Jenkins environment config
sudo nano /etc/default/jenkins
# Find the JAVA_ARGS line and replace with the tuned settings below
# Rule of thumb: set heap to 50-60% of total RAM, never more than 75%
# This server has 16 GB RAM → allocate 8 GB to Jenkins heap
JAVA_ARGS="\
-Xms8g \
-Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+ExplicitGCInvokesConcurrent \
-Djava.awt.headless=true \
-Xlog:gc:/var/log/jenkins/gc.log:time,uptime:filecount=5,filesize=20m"
# The options explained:
# -Xms8g -Xmx8g → start and max heap both 8 GB (no resizing)
# -XX:+UseG1GC → G1 garbage collector — better for large heaps
# -XX:MaxGCPauseMillis=200 → target max GC pause of 200ms
# -XX:+ExplicitGCInvokesConcurrent → don't stop the world for explicit GC calls
# -Djava.awt.headless=true → Jenkins needs no display — avoids AWT errors
# -Xlog:gc → write GC events to a rolling log file
# Apply the changes by restarting Jenkins
sudo systemctl restart jenkins
# Verify the new JVM settings are active
# Look for -Xmx8g in the process arguments
ps aux | grep jenkins | grep -o "\-Xm[xs][^ ]*"
# Check the current heap usage via the Jenkins script console
# Paste this into http://JENKINS_URL/script
java -jar jenkins-cli.jar \
-s http://jenkins-master-01:8080 \
-auth admin:your-api-token \
groovy = << 'EOF'
def rt = Runtime.getRuntime()
def mb = 1024 * 1024
println "JVM Heap Settings:"
println " Max heap: ${rt.maxMemory() / mb} MB"
println " Total heap: ${rt.totalMemory() / mb} MB"
println " Used heap: ${(rt.totalMemory() - rt.freeMemory()) / mb} MB"
println " Free heap: ${rt.freeMemory() / mb} MB"
println " Heap usage: ${ ((rt.totalMemory() - rt.freeMemory()) * 100 / rt.maxMemory()).round(1) }%"
EOF
Where to practice: For Docker Jenkins, pass JVM options via the JAVA_OPTS environment variable: docker run -e JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC" jenkins/jenkins:lts-jdk21. Start with a smaller heap for local testing. Full JVM tuning guide at jenkins.io — JVM Options.
# ps aux output: -Xms8g -Xmx8g # Groovy heap check output: JVM Heap Settings: Max heap: 8192 MB Total heap: 8192 MB Used heap: 3847 MB Free heap: 4345 MB Heap usage: 46.9%
What just happened?
- Heap confirmed at 8 GB — the
ps auxgrep and the Groovy script both confirm the new heap settings are active. The JVM is using 3.8 GB of 8 GB available — 47% utilisation under normal load. Good. At peak (9–11 AM) this might reach 70–75%. That's healthy — it means there's room to grow before GC pressure kicks in. - -Xms equals -Xmx — both set to 8g. This prevents the JVM from starting small and slowly growing the heap, which causes frequent GC runs during warmup. Starting at max size means heap allocation happens once at startup.
- G1GC is active — the G1 (Garbage First) collector manages the heap in regions and tries to keep GC pauses under 200ms. For a long-running process like Jenkins with a large heap, G1 is significantly better than the old default Parallel collector.
- GC log enabled — events are being written to
/var/log/jenkins/gc.logwith 5 rolling files of 20 MB each. After a week, read this log to see if GC pauses are too frequent or too long — the data you need to decide if the heap needs further adjustment.
Build Log Retention — Stop Hoarding History
Build logs are the biggest driver of disk usage on a Jenkins server. A pipeline that runs 50 times a day with 1 MB of console output per run generates 50 MB per day — 1.5 GB per month — per job. With 50 active jobs, that's 75 GB per month. Without retention limits, Jenkins disks fill in weeks.
// Run in the Jenkins Script Console to apply log rotation to ALL jobs at once
// This is useful when you inherit a Jenkins with hundreds of jobs and no retention policy
import jenkins.model.Jenkins
import hudson.tasks.LogRotator
def maxBuilds = 30 // keep the last 30 builds per job
def maxDays = 30 // also discard builds older than 30 days
def maxArtifactBuilds = 5 // keep artifacts from only the last 5 builds
def maxArtifactDays = 7 // discard artifacts older than 7 days
int updated = 0
Jenkins.instance.getAllItems(hudson.model.Job).each { job ->
// Only update jobs that have NO existing log rotation configured
// Don't overwrite jobs where someone has already set a specific policy
if (job.buildDiscarder == null) {
job.buildDiscarder = new LogRotator(
maxDays, // daysToKeep
maxBuilds, // numToKeepStr
maxArtifactDays, // artifactDaysToKeep
maxArtifactBuilds // artifactNumToKeep
)
job.save()
updated++
println "Updated: ${job.fullName}"
}
}
println "\nDone — applied log rotation to ${updated} jobs."
println "Jobs already having a retention policy were skipped."
Updated: payments/payment-service-build Updated: payments/payment-api-deploy Updated: frontend/frontend-test Updated: frontend/frontend-build-main Updated: platform/infra-health-check Updated: platform/agent-maintenance ... Done — applied log rotation to 43 jobs. Jobs already having a retention policy were skipped.
What just happened?
- 43 jobs updated in one script run — instead of opening each job individually and ticking the Discard old builds checkbox, the script applied the policy to every job that was missing one. This would have taken hours in the UI.
- Existing policies not overwritten — the
if (job.buildDiscarder == null)check means jobs that already had a specific retention policy (e.g. a compliance job keeping 90 days of history) were left untouched. - Artifacts handled separately — artifacts are large files (JARs, Docker images, test reports). Keeping only 5 builds of artifacts while keeping 30 builds of logs is a smart balance — you still have log history for debugging but don't waste disk on stale binary artifacts.
- Disk reclamation happens immediately — Jenkins starts deleting old builds that now exceed the new retention limits on the next scheduled cleanup. You'll typically see disk usage drop within minutes of running this script on a server with a lot of history.
Workspace and Fingerprint Cleanup
Build workspaces accumulate on agents even after builds complete. Old workspaces from deleted jobs or renamed pipelines keep consuming disk forever unless explicitly cleaned. Jenkins also maintains a fingerprint database — a record of every file artifact and which build produced it. This database grows without bound and is rarely used but often consumes gigabytes.
// Run in Script Console — clean up stale workspaces and orphaned data
import jenkins.model.Jenkins
// -------------------------------------------------------
// PART 1: Clean orphaned workspaces on all agents
// These are workspace directories for jobs that no longer exist
// -------------------------------------------------------
Jenkins.instance.nodes.each { node ->
// Get the workspace root on this agent
def wsRoot = node.rootPath
if (wsRoot == null) {
println "Skipping ${node.name} — agent offline"
return
}
def cleaned = 0
wsRoot.list().each { dir ->
// Check if a job with this workspace name still exists
def jobName = dir.name
def job = Jenkins.instance.getItemByFullName(jobName)
if (job == null) {
// Job no longer exists — this workspace is orphaned
println "Deleting orphaned workspace: ${node.name}/${dir.name}"
dir.deleteRecursive()
cleaned++
}
}
println "${node.name}: cleaned ${cleaned} orphaned workspace(s)"
}
// -------------------------------------------------------
// PART 2: Clean the fingerprint database
// Fingerprints record which builds produced which artifacts
// Old fingerprints reference deleted builds — safe to clean
// -------------------------------------------------------
def fp = Jenkins.instance.fingerprintMap
if (fp != null) {
def before = fp.size()
Jenkins.instance.cleanupFingerprint()
def after = fp.size()
println "\nFingerprint cleanup: ${before - after} orphaned records removed"
println "Remaining fingerprints: ${after}"
}
agent-linux-01: cleaned 7 orphaned workspace(s) Deleting orphaned workspace: agent-linux-01/old-checkout-service-v1 Deleting orphaned workspace: agent-linux-01/deprecated-auth-service Deleting orphaned workspace: agent-linux-01/test-pipeline-2023 agent-linux-02: cleaned 3 orphaned workspace(s) Deleting orphaned workspace: agent-linux-02/feature-branch-abandoned Deleting orphaned workspace: agent-linux-02/payments-service-OLD Fingerprint cleanup: 14,847 orphaned records removed Remaining fingerprints: 2,103
What just happened?
- 10 orphaned workspaces deleted across two agents — these directories were left behind when jobs were deleted or renamed months ago. Each directory contained the full source code and build output of those jobs. The disk reclamation could be several gigabytes depending on the project sizes.
- 14,847 fingerprint records removed — these were entries in Jenkins' artifact tracking database pointing to builds that no longer exist. The fingerprint database was consuming significant JENKINS_HOME disk space for data Jenkins couldn't actually use.
- 2,103 fingerprints remain — these belong to active builds and are kept. The cleanup only removes records referencing deleted builds.
- This script is safe to run on a live Jenkins — it checks job existence before deleting any workspace, and Jenkins' built-in cleanup handles fingerprints atomically. Run it monthly as part of your maintenance schedule.
Performance Tuning Checklist
When Jenkins starts running slowly, work through this list in order — earlier items have the most impact and take the least effort.
| Priority | Action | Typical impact |
|---|---|---|
| High | Increase JVM heap to 50–60% of available RAM with G1GC | Eliminates OOM errors, reduces GC pauses, faster UI |
| High | Apply build log retention to all jobs (max 30 builds / 30 days) | Significant disk reclamation — can free 50–90% of disk on older servers |
| Medium | Delete orphaned workspaces and clean fingerprint database | Frees agent disk, speeds up Jenkins startup and job loading |
| Medium | Remove unused plugins — every plugin adds startup time and memory | Faster Jenkins startup, less heap consumed by idle plugin code |
| Medium | Organise jobs into folders — flat job lists with 200+ jobs are slow to render | Dashboard and job list load times drop significantly |
| Low | Enable quiet period — adds a short delay before builds start to batch rapid commits | Reduces redundant builds when developers push multiple commits in quick succession |
| Low | Move builds off the master — use dedicated agents (Lesson 4) | Master stays responsive — not competing with build processes for CPU and memory |
Plugin Performance — The Hidden Cost
Every installed plugin adds startup time, heap consumption, and request handling overhead — even if you never actively use it. The performance cost is not zero for disabled plugins, and for active plugins it scales with every Jenkins request. A Jenkins with 150 plugins is noticeably slower to start and respond than one with 40 well-chosen plugins.
// Find plugins that have never been used in any pipeline — candidates for removal
// 'Never used' is a heuristic — verify manually before uninstalling
import jenkins.model.Jenkins
def pm = Jenkins.instance.pluginManager
// List all installed plugins sorted by name
// Cross-reference with your actual pipeline steps to identify unused ones
pm.plugins.sort { it.shortName }.each { plugin ->
def deps = plugin.getDependants().size() // how many other plugins depend on this one
def active = plugin.isActive() ? "" : " [DISABLED]"
println "${plugin.shortName.padRight(45)} v${plugin.version.padRight(20)} dependants: ${deps}${active}"
}
println "\nTotal plugins: ${pm.plugins.size()}"
println "Tip: plugins with 0 dependants and that you don't actively use are candidates for removal."
ant v497.v94... dependants: 0 blueocean v1.27.4 dependants: 12 cobertura v1.17 dependants: 0 [DISABLED] credentials v1319.v7eb... dependants: 31 docker-workflow v1.27 dependants: 3 git v5.2.1 dependants: 8 gradle v2.11 dependants: 0 kubernetes v3845.v7b... dependants: 2 slack v2.51 dependants: 0 timestamper v1.26 dependants: 0 Total plugins: 10 Tip: plugins with 0 dependants and that you don't actively use are candidates for removal.
What just happened?
anthas 0 dependants — no other plugin depends on Ant, and if your team doesn't use Ant builds, this plugin is pure overhead. Uninstalling it saves a small amount of startup time and heap.coberturais DISABLED with 0 dependants — this was identified in Lesson 26 as well. It's taking disk space and still loaded into the JVM at startup even though it's disabled. Uninstall it entirely.gradlehas 0 dependants — if your pipelines call Gradle viash './gradlew'directly (which is the recommended approach), you don't need the Gradle plugin at all. It's only needed if you're using the old Gradle build step in Freestyle jobs.credentialshas 31 dependants — never uninstall this. Removing it would break every other plugin that stores or accesses credentials. High dependant count = leave it alone.- The audit helps prioritise — plugins with 0 dependants that you don't actively reference in your Jenkinsfiles are the first candidates for removal. Always check the Jenkins update centre first to make sure the plugin isn't a dependency of something you do use.
Teacher's Note
Run the JVM heap check and the build log retention script on every Jenkins server you inherit. Together they fix 80% of performance problems in under 30 minutes.
Practice Questions
1. Which JVM flag sets the maximum heap size that Jenkins is allowed to use?
2. Which Java class do you use in the Groovy script console to programmatically set build log retention on Jenkins jobs?
3. Which Java garbage collector is recommended for Jenkins due to its shorter pause times with large heaps?
Quiz
1. Why should you set -Xms equal to -Xmx for a production Jenkins server?
2. What is the most common cause of disk space exhaustion on a Jenkins server?
3. Which plugins are safe candidates for removal when tuning Jenkins for performance?
Up Next · Lesson 34
Distributed Builds
When one agent isn't enough — scaling Jenkins horizontally with multiple agents, labels, and agent pools to handle large build loads.