This is part three of three. Beginner taught you to run a pipeline, and Mid taught you to write good ones. This level is about the thing underneath: a Jenkins controller that thirty teams depend on, that holds every deploy credential the company owns, and that has no standby replica. When it is slow, nobody ships. When it is compromised, every system it can deploy to is compromised with it.
That combination is what makes Jenkins a platform problem rather than a tooling problem. The software is free and flexible, and the flexibility is exactly the risk: anyone who can edit a Pipeline can run code on your agents, and the controller is a single stateful Java process with a file-based data store. By the end of this guide you should be able to explain where that process breaks first, how to split it, how to secure it, how to upgrade it without a bad weekend, and, just as important, when to stop adding to it and move the work elsewhere. This guide targets Jenkins LTS 2.580.1 on Java 21 (Java 25 is also supported).
Where this picks up
| Topic from earlier levels | What this level adds |
|---|---|
| Controller, agents, executors | The failure modes of a single stateful controller, and what you do instead of high availability |
buildDiscarder, timeouts |
Sizing and retention as capacity planning, not housekeeping |
| Declarative and Scripted Pipeline | CPS internals, durability settings, and why Groovy on the controller is the real scaling limit |
Credentials and withCredentials |
The trust model: who can reach which secret through which permission |
| Shared libraries | Trusted versus untrusted libraries as a privilege boundary |
| Kubernetes agents | Pod templates as a fleet: caps, idle time, retry, and what breaks under load |
| JCasC | Configuration as a reviewed, reloadable artifact, and its sharp edges |
| Plugins | Plugin management as supply-chain management |
| Upgrades | The 2.541 to 2.580 line: what changed, what breaks, and a rehearsed procedure |
| new | Multi-tenancy, cost, backup and restore, incident playbooks, governance, and when Jenkins is the wrong tool |
I am starting with architecture, because every decision below follows from the shape of the controller.
Architecture and failure modes
A Jenkins controller is one Java process (served by Winstone, which embeds Jetty) that owns three things at once: the scheduler that decides which build runs where, the web tier that serves the UI and the REST API, and the state, which lives as files in JENKINS_HOME. There is no database server. Job configuration is XML, build records are files under each job's builds/ directory, credentials are encrypted blobs in XML, and plugins are archives unpacked into plugins/. The core is not active-active and not highly available. The open-source answer to "what if it dies" is to restart it quickly from persistent storage, and everything in a sound design is arranged to make that restart fast and safe.
Why this shape matters: every failure below is one of two kinds. Either the single process is starved (heap, CPU, threads, disk I/O), or the single copy of state is damaged or leaked. Scaling work addresses the first kind and backup and security work address the second.
The practical failure modes fall into a small number of families, and learning to recognise which family you are in shortens every incident.
Heap exhaustion. The controller keeps job configuration, the build queue, plugin state and the in-flight state of every running Pipeline in memory. Huge console logs, unbounded build history, large objects held in Pipeline variables and big JsonSlurper payloads all sit in that heap. The symptom is java.lang.OutOfMemoryError: Java heap space, or before it arrives, garbage-collection thrash where the UI is unresponsive and builds still appear to run. Raising -Xmx buys time, but the durable fix is to keep less in memory: buildDiscarder everywhere, logic moved to agents, and heap dumps enabled with -XX:+HeapDumpOnOutOfMemoryError so the next occurrence is diagnosable rather than mysterious.
CPU starvation from Pipeline Groovy. This one surprises people, and it is covered in its own section below. Pipeline code executes on the controller, not on the agent. One hundred concurrent pipelines each doing string manipulation in Groovy are one hundred tenants sharing the controller's cores.
Disk I/O. Pipelines write their state to disk as they run, and logs stream to disk continuously. On slow or network-attached storage the controller spends its time waiting on fsync. The Jenkins documentation's sizing advice puts storage size ahead of speed for the controller, but for Pipeline-heavy workloads SSD matters, and the durability setting described later trades safety for less I/O.
Thread and connection pressure. Every agent connection, every webhook, every API poller and every browser tab holds resources. A dashboard that polls /api/json with depth=3 every few seconds for hundreds of jobs can do more damage than a hundred builds. The fix is webhooks instead of polling and ?tree= filters on every API call you control.
The plugin blast radius. Nearly all functionality is a plugin, plugins share the controller's JVM and class loader space, and a plugin that misbehaves can degrade or crash the whole process. This is why a test controller for plugin upgrades is an architectural requirement, not a luxury.
State damage and state leaks. A corrupted disk, a bad upgrade that rewrites configuration, or an operator deleting the wrong folder. And, worse, the leak: JENKINS_HOME contains the keys (secrets/) that decrypt every credential in it, so a stolen backup is a stolen credential store.
Two architectural consequences follow. First, the built-in node should have zero executors in production (Manage Jenkins, Nodes, Built-In Node, Configure). A build on the built-in node has the same access to the controller's file system as the Jenkins process itself, which means any Pipeline author can read secrets/ and every other team's job configuration. Second, because the controller cannot be made redundant, you reduce its blast radius by running several of them, each smaller, each owned by a clear group, and each reconstructible from code.
/login returning 200 is the common liveness check, and it is what the Helm chart uses) so the orchestrator restarts a hung controller instead of a human. Then rehearse the restart, because an untested recovery path is a hope.
- Open Manage Jenkins, then Nodes, and look at the Built-In Node. If it has executors, set it to 0 and confirm that a job labelled for an agent still runs.
- Find your real
JENKINS_HOMEfrom System Information and rundu -shonjobs/,plugins/andsecrets/. Which one is largest, and is it what you expected? - Kill the controller process during a long
sleepin a Pipeline and start it again. Read the console output of the build to see what "resuming" looks like.
What breaks first when you scale
It helps to think of scale on three axes that do not move together: the number of jobs (configuration and scan load), the number of concurrent builds (scheduler, Groovy CPU and log I/O), and the number of users and API clients (web tier). The documentation's planning heuristics are deliberately rough, and worth quoting as orders of magnitude rather than promises: roughly 500 jobs per controller, and executors in the region of the job count multiplied by 0.03. They are a prompt to measure, not a formula.
In practice the order in which things break is usually this.
- The API and UI, from polling. External tools that ask Jenkins "what is the status of everything?" on a timer are the most common self-inflicted load. Replace them with webhooks and event notifications, and cap the depth of every query.
- Branch indexing and SCM polling. A Multibranch Pipeline or Organization Folder scans repositories to find branches and pull requests. With hundreds of repositories, scheduled scans collide. Webhooks drive scans on demand, and the periodic scan becomes a slow safety net rather than the main trigger.
- Pipeline Groovy CPU. Covered in detail in the next section. It is the limit that does not show up in any "number of executors" arithmetic.
- The queue and executor supply. This is the good problem, because ephemeral agents (next section but one) absorb it. The controller's scheduler, not the agent fleet, is what eventually saturates.
- Heap and disk. Long retention, giant logs and thousands of builds per job push these. Retention policy is capacity planning.
- Plugin interactions. Past a certain plugin count, the upgrade matrix itself becomes the scaling limit. You can measure it by how long it takes to get a plugin update through staging.
The response is not a bigger machine beyond a point. The documentation's own guidance on horizontal scaling is to split controllers by product line, organisation or environment, and its sizing advice is to "invest in a larger machine for your controller over a faster one", meaning that capacity (memory, cores) is worth more than raw clock speed. A controller per business unit, each with its own agents and its own credentials, is also the only strong multi-tenancy Jenkins offers, which is a point I return to in the multi-tenancy section.
Measure before deciding. The Prometheus plugin exposes the signals that tell you which axis you are on, and the queue is the honest one: a queue that is long while executors are idle means a scheduling or label problem, while a long queue with every executor busy means you need more agents. A controller that is slow while the queue is empty means you are CPU-, heap- or disk-bound on the controller itself.
# Is the controller the bottleneck, or are we short of agents?
# Queue length and how long items have waited
curl -s -u "$JENKINS_USER_ID:$JENKINS_API_TOKEN" \
"https://ci.example.com/queue/api/json?tree=items[why,inQueueSince,task[name]]"
# Busy versus total executors across all nodes
curl -s -u "$JENKINS_USER_ID:$JENKINS_API_TOKEN" \
"https://ci.example.com/computer/api/json?tree=busyExecutors,totalExecutors"
The why field in the queue output is gold. It says things like Waiting for next available executor on ‘linux’ or There are no nodes with the label ‘linux’, which tell you in a sentence whether the problem is capacity, a missing label, or a cloud cap.
- Run the two
curlcommands above against your controller (with a valid API token) and compute executor utilisation. - Enable the Prometheus plugin on a test controller and open
/prometheus/. The trailing slash is required. Find the queue and executor metrics. - List every external system that calls your Jenkins API on a timer. For each, write down whether a webhook could replace it.
Pipeline execution, CPS and durability
The single most useful fact about Jenkins performance is that Pipeline Groovy runs on the controller. The steps that do real work, such as sh and bat, run on the agent. But the Groovy that decides what to do next, loops over lists, concatenates strings and calls your shared library runs in the controller's JVM, under the Continuation-Passing Style (CPS) interpreter.
CPS exists so that a Pipeline can be serialised to disk and resumed after a restart. Every time execution reaches a step, the interpreter can capture its whole state. That is a wonderful property and it has a price: CPS-transformed Groovy is much slower than ordinary Groovy, every local variable that is live across a step must be serialisable, and the cost scales with how much Groovy you write. A Pipeline that parses a 20 MB JSON file with JsonSlurper in Groovy is doing a heavy job on the shared controller. The same job in jq inside an sh step costs the controller nothing.
The rules that follow are the ones a platform team enforces in review:
- Groovy is glue. Real work belongs in
shsteps on agents, or in tools invoked by them. - Combine many tiny
shsteps into one script. Each step has fixed overhead on the controller. - Avoid controller-side HTTP calls and big parsing in Pipeline code. Use
readJSON(from Pipeline Utility Steps) for small data andjqor a script for large data. - Keep shared libraries lean and do not make them load enormous data at startup.
- Never call steps from
@NonCPSmethods, and never hold non-serialisable objects across step boundaries.
The error messages teach the model. java.io.NotSerializableException: java.util.regex.Matcher means a non-serialisable object was still live when CPS tried to persist state, so compute inside a @NonCPS method that returns plain data. And expected to call java.util.ArrayList.toSorted but wound up catching org.jenkinsci.plugins.workflow.cps.CpsClosure2.call means non-CPS code (a Java or GDK method) tried to call a CPS closure, so do not pass Pipeline closures into GDK methods.
// Bad: heavy parsing runs in CPS on the controller
def data = new groovy.json.JsonSlurperClassic().parseText(readFile('report.json'))
// Better: let the agent do the work and hand back one small value
def failures = sh(script: "jq '.failures | length' report.json", returnStdout: true).trim()
echo "failures: ${failures}"
Durability: what you trade for speed
Because Pipelines persist their state as they run, how often and how carefully they do it is a tunable. Jenkins offers three durability hints. MAX_SURVIVABILITY is the original behaviour and the default: the slowest, with the strongest guarantees. SURVIVABLE_NONATOMIC writes less carefully. PERFORMANCE_OPTIMIZED does much less disk I/O, but a dirty shutdown (a crash or power loss, as opposed to a graceful restart) can lose the state of running Pipelines.
That is a genuine trade-off and a good interview topic. A fleet of short CI builds on network storage benefits enormously from PERFORMANCE_OPTIMIZED, because a lost running build is simply re-run. A release Pipeline that takes six hours and waits on human approval should stay at the safe setting. You can set it globally (Manage Jenkins, System, Pipeline Speed/Durability Settings), per job, or in the Jenkinsfile:
pipeline {
agent { label 'linux' }
options {
durabilityHint('PERFORMANCE_OPTIMIZED') // short CI run: a lost build is just re-run
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20'))
}
stages {
stage('Build') { steps { sh 'make build' } }
}
}
A related option for long-lived platforms is disableResume(). By default a Pipeline tries to resume after a controller restart, which is what you want until an agent has vanished and the build sits in Resuming build ... Waiting to resume part of ... : ‘agent-x’ is offline. For ephemeral Kubernetes agents, resuming a build whose pod no longer exists is pointless, and the Kubernetes plugin's retry(count: 2, conditions: [kubernetesAgent(), nonresumable()]) pattern gives you a cleaner answer.
- Write a Pipeline that loops 100,000 times in Groovy doing string concatenation, then the same loop in an
shstep. Compare the stage durations and watch controller CPU during each. - Trigger
java.io.NotSerializableExceptionon purpose by keeping aMatcherin a variable across anecho, then fix it with a@NonCPShelper. - Set
durabilityHint('PERFORMANCE_OPTIMIZED')on a job and compare disk writes underJENKINS_HOMEwith the default for the same workload.
Agents as a fleet
At the platform level agents stop being machines you care about and become fungible capacity. The documentation's advice is to standardise them so any agent can run any job, which means jobs declare what they need (a label, or a pod spec) and never depend on a particular host.
There are three ways agents attach to the controller. SSH agents are the ones the controller connects out to: the documentation treats them as the most stable, they need the SSH Build Agents plugin and an "SSH Username with private key" credential, and host key verification matters (choose Known hosts file or a manually provided key, and avoid "Non verifying"). Inbound agents connect in from the agent side, which is firewall-friendly, and with -webSocket they need no extra port and pass through an ordinary HTTP reverse proxy. Without -webSocket they use the inbound TCP port (50000 in the container images), which is disabled by default (-1) since Jenkins 2.0. Clouds create agents on demand and delete them afterwards.
# An inbound agent over WebSocket: no TCP 50000, works through a reverse proxy
curl -sO https://ci.example.com/jnlpJars/agent.jar
java -jar agent.jar \
-url https://ci.example.com/ \
-secret @secret-file \
-name "linux-01" \
-webSocket \
-workDir "/home/jenkins/agent"
The security model of the connection is worth stating. Agent-to-controller security has been on since 2.326 and cannot be switched off in the UI, so an agent cannot ask the controller to read arbitrary files. That does not make agents safe to share between trust levels: an agent runs whatever the build tells it to, and anything one build leaves behind (a workspace, a Docker socket, a cached credential) is visible to the next build on the same machine. Ephemeral agents solve this by construction.
The Kubernetes cloud
For most platform teams the answer is the Kubernetes plugin, which creates a pod per build and deletes it afterwards. The agent container is named jnlp (image jenkins/inbound-agent), and Pipelines describe their own toolchain as extra containers.
pipeline {
agent {
kubernetes {
defaultContainer 'maven'
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: maven
image: maven:3.9.9-eclipse-temurin-21
command: ["sleep"]
args: ["infinity"]
'''
}
}
stages { stage('build') { steps { sh 'mvn -B verify' } } }
}
The settings a platform owner actually tunes are few but consequential. The container cap is a concurrency limit on the cloud: it stops a burst of builds from asking the cluster for more pods than it can hold, and when it is reached builds queue with a "waiting" reason rather than failing. idleMinutes keeps a pod alive between builds to save start-up time, at the price of idle cost and some state leakage. podRetention (never(), onFailure(), always(), evicted()) controls whether finished pods remain for debugging. activeDeadlineSeconds kills runaway pods. Use WebSocket when the controller is reachable only over HTTP(S). When containers in one pod must share the workspace, they need the same UID through securityContext.runAsUser, or you will chase permission errors.
The typical fleet failures are all visible from kubectl. Pods stuck in Pending mean the cluster has no capacity or the requests are too large; ImagePullBackOff means a bad image or missing pull secret; repeated pod creation failures in the controller log mean the service account lacks rights in the namespace. If you run the cluster yourself, the Kubernetes guide covers the scheduling side, and the controller itself is commonly installed with the Helm chart, whose values are the place to set controller.installPlugins, controller.JCasC.configScripts and persistence.storageClass.
Fungible agents
- Pods created per build and deleted after
- Toolchain declared in the Jenkinsfile's pod spec
- One executor per agent
- A cloud container cap so bursts queue instead of overwhelming the cluster
- Agents rebuilt from an image, never patched by hand
Pet agents
- Long-lived VMs with tools installed by hand
- Jobs pinned to
agent-07by name - Many executors sharing one workspace area and one Docker socket
- No cap, so a burst takes the cluster down
- State left over from the previous tenant's build
remoting.jar is 3176.v207ec082a_8c0. The weekly 2.584 also dropped support for Remoting versions released before August 2024. An agent on Java 17 fails with java.lang.UnsupportedClassVersionError: hudson/remoting/Launcher has been compiled by a more recent version of the Java Runtime (class file version 65.0). Use the Versions Node Monitors plugin to see every agent's JVM before you upgrade, not after.
- Set a low container cap on a test Kubernetes cloud, start ten builds at once, and read the queue reasons on the ones that wait.
- Run the same build with
idleMinutesset to 0 and to 10. Compare start-up time and think about what leaks between builds in the second case. - Install Versions Node Monitors and check which agents would fail a move to Java 21.
A tour of the interface
Before building anything, spend five minutes learning where things are. Jenkins' interface has been redesigned several times, and recent LTS lines also include experimental redesigned pages, so a screenshot in an older tutorial may not match exactly. The landmarks below are stable.
The dashboard is the home page. It lists your jobs with their last status, and on the left has New Item (create a job), Build History and Manage Jenkins. The build queue and the executor status sit in a side panel, so you can see at a glance whether work is waiting.
Manage Jenkins is the administrator's control room. You will visit four places here again and again. System holds global settings such as the Jenkins URL. Plugins lets you install and update plugins. Nodes lists the machines that run builds, including the built-in node. Credentials stores secrets. There is also a Tools page for configuring things like a JDK or Maven by name, and a System Log page that shows what Jenkins itself is saying. When something odd happens, Manage Jenkins often shows a yellow or red banner explaining it, so read the banners before you search the internet.
A job page shows the job's name, a Build Now button, its build history and, for pipelines, a graphical view of the stages. Click a build number such as #3 and you reach the build page, which has Console Output, the full text log of everything that ran. Console Output is the most useful page in Jenkins. When a build fails, the answer is almost always in there, a few lines above the red text.
Pipeline Syntax, at /pipeline-syntax on your server, is a helper called the Snippet Generator. You choose a step from a dropdown, fill in a form, and it writes the exact code for the plugins you actually have installed. Use it constantly; it is more reliable than memory and more current than a blog post. A sibling tool, the Declarative Directive Generator, does the same for blocks such as environment and options.
- Open Manage Jenkins and click through Plugins, Nodes and Credentials without changing anything.
- In Nodes, find the Built-In Node and note how many executors it has.
- Visit
http://localhost:8080/pipeline-syntaxand generate a snippet for theechostep.
echo 'hello' in the right form. You have found the three places you will use most.
Your first pipeline
Time to build something. The plan is to create a pipeline job whose script is typed directly into Jenkins. That is a teaching shortcut; a few sections from now you will move the script into a repository, which is how real projects do it.
- On the dashboard click New Item.
- Type a name,
hello-pipeline, choose Pipeline and click OK. - Scroll to the Pipeline section. Leave Definition as "Pipeline script".
- Paste the script below into the box and click Save.
pipeline {
agent any
stages {
stage('Greet') {
steps {
echo 'Hello from Jenkins'
}
}
stage('Look around') {
steps {
sh 'whoami'
sh 'pwd'
sh 'uname -a'
}
}
}
}
Now click Build Now. A new entry, #1, appears in the build history. Click it, then Console Output. You should see something close to this:
Started by user admin
[Pipeline] Start of Pipeline
[Pipeline] node
Running on Jenkins in /var/jenkins_home/workspace/hello-pipeline
[Pipeline] stage
[Pipeline] { (Greet)
[Pipeline] echo
Hello from Jenkins
[Pipeline] }
[Pipeline] stage
[Pipeline] { (Look around)
[Pipeline] sh
+ whoami
jenkins
[Pipeline] sh
+ pwd
/var/jenkins_home/workspace/hello-pipeline
[Pipeline] sh
+ uname -a
Linux 6.x ... x86_64 GNU/Linux
[Pipeline] End of Pipeline
Finished: SUCCESS
Read this log the way you will read every log. The lines starting [Pipeline] are Jenkins narrating its own progress. Running on Jenkins in ... tells you which node ran the build and which workspace directory it used; "Jenkins" is the name of the built-in node on a fresh install. A line starting with + is a shell command that the sh step ran, and the output follows it. The last line, Finished: SUCCESS, is the build's result.
If your log says whoami printed jenkins, that teaches something useful: the build ran as the operating-system user that runs the Jenkins service, which is why giving Jenkins broad privileges is dangerous.
What each part of the script does
pipeline { ... } is the outer block that marks this as a Declarative Pipeline. Everything lives inside it.
agent any says "run this on any available node". agent is required; Jenkins refuses a Declarative script without one. You will meet stricter choices in a later section.
stages { ... } contains one or more stage blocks, which are required too. Each stage has a name, which is what you see in the graphical view, and a steps { ... } block holding the actions. echo prints a message. sh runs a shell command and fails the stage if the command exits with a non-zero status. On a Windows agent you would use bat or powershell instead of sh.
The last point deserves emphasis because it is how Jenkins decides success and failure. A step fails when something goes wrong, and a failed step fails its stage and then the build. For shell commands, "goes wrong" means a non-zero exit status, which is the universal convention for "this command did not succeed". That is why test commands such as pytest or mvn test can be used directly: if a test fails, the tool exits non-zero, and Jenkins marks the build red.
pipeline { ... }), which is structured, validated before it runs and the recommended starting point. Scripted (node { ... }) is plain Groovy with more freedom and more ways to hurt yourself. You will read Scripted code written by others, and the Mid-level guide covers it, but write Declarative first.
Now break it on purpose. Edit the pipeline (Configure), change sh 'uname -a' to sh 'false', save and build again. The console ends with:
+ false
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // stage
[Pipeline] End of Pipeline
ERROR: script returned exit code 1
Finished: FAILURE
ERROR: script returned exit code 1 is the most common failure message in Jenkins, and it is not an error in Jenkins. It means the command you ran returned a failure status, so look above this line for that command's own output. The command false is a real program that does nothing and always fails, which makes it handy for tests like this one.
- Create the
hello-pipelinejob and run it until you getFinished: SUCCESS. - Change one step so it fails, run it, and find the line that explains why.
- Fix it and run again, then compare build #1, #2 and #3 in the build history.
Writing a real Jenkinsfile
The script in the box is easy, but it has a serious flaw: it lives inside Jenkins, where it cannot be reviewed, versioned or restored. The standard practice is to store the pipeline as a file named Jenkinsfile at the root of the project repository. That gives you the benefits you already take for granted with code: history, code review, branches, and the ability to rebuild an old version exactly as it was.
Here is a slightly more realistic Jenkinsfile. It builds and tests a generic project that uses make, which stands in for whatever your language uses.
pipeline {
agent any
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20'))
}
environment {
APP = 'demo'
}
stages {
stage('Build') {
steps {
sh 'make build'
}
}
stage('Test') {
steps {
sh 'make test'
}
post {
always {
junit 'reports/**/*.xml'
}
}
}
}
post {
success {
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
failure {
echo 'The build failed'
}
cleanup {
cleanWs()
}
}
}
Go through the blocks one at a time.
options sets behaviour for the whole pipeline. timeout(time: 30, unit: 'MINUTES') aborts the build if it runs for more than half an hour. Without a timeout, a hung test can occupy an executor for ever. buildDiscarder(logRotator(numToKeepStr: '20')) keeps only the last twenty builds' records. Without it, build history grows until the disk fills, and this is one of the most common causes of a stuck Jenkins. Put both options in every Jenkinsfile from day one.
environment defines environment variables for every step. Inside a shell step, use them as $APP. Inside Groovy code, read them as env.APP.
post holds actions that run after the stages finish, depending on the outcome. The conditions available are always, changed, fixed, regression, aborted, failure, success, unstable, unsuccessful and cleanup, evaluated in that order, with cleanup last so that it runs whatever happened. A post block can sit at the pipeline level, as here, or inside a single stage. The test stage uses always to publish test reports even when tests fail. That matters: the moment tests fail is the moment you most want the report.
junit 'reports/**/*.xml' reads test results in the JUnit XML format, which almost every test tool can produce, and gives you a test-results page with trends. It needs the JUnit plugin. Note the quoting: the path is a pattern, and the ** means "any number of directories".
archiveArtifacts saves files from the workspace as downloadable results of the build. fingerprint: true records a checksum so Jenkins can tell where a particular file came from.
cleanWs() deletes the workspace. It belongs to the Workspace Cleanup plugin, so it fails with "No such DSL method" if that plugin is missing.
def x = 1. If you try, you get Expected a step. When you truly need a variable or an if, wrap the code in a script { ... } block, which is the escape hatch into Scripted Groovy.
A few practical points about shell steps. Use single quotes around shell commands: sh 'echo $APP' lets the shell expand $APP, which is what you want. Groovy treats double-quoted strings specially, expanding $APP itself before the shell ever sees it. For multi-line commands use triple single quotes:
steps {
sh '''
set -e
echo "Building $APP build number $BUILD_NUMBER"
make build
'''
}
Jenkins runs sh with -xe, printing each command and stopping at the first failure. Writing set -e yourself is harmless and makes the intent clear.
Validating before you commit
Declarative scripts are checked before any step runs, so syntax mistakes produce errors like Undefined section "foo" or Missing required section "agent" straight away. You can check a file without running it, using the Declarative Linter built into the server:
curl -X POST -F "jenkinsfile=<Jenkinsfile" -u user:token \
http://localhost:8080/pipeline-model-converter/validate
A valid file returns Jenkinsfile successfully validated. This saves you from commit-push-wait-fail loops. (The user:token part is an API token; you will create one in the section on the command line and REST API.)
- Create a small Git repository on your machine with a
Jenkinsfilecontaining the example above, replacingmake buildandmake testwithecho buildingandecho testing. - Remove the
junitandarchiveArtifactslines for now, since there are no reports or files yet. - Introduce a typo such as
stagezand validate it with the linter or by pasting it into a job.
Getting the code: Pipeline from SCM
A build needs the source code. There are two ways to give it to Jenkins, and the difference is important.
The first is a Pipeline job that reads its script from source control. You tell Jenkins the repository address and the path of the Jenkinsfile, and Jenkins checks the repository out and runs what it finds. Create it like this: New Item, name it, choose Pipeline, and in the Pipeline section change Definition to Pipeline script from SCM. Choose Git as the SCM, paste the repository URL, pick credentials if the repository is private (next section), set the branch, for example */main, and keep Script Path as Jenkinsfile.
In a job like this, the checkout of the repository happens automatically before your first stage, which is why the simple Jenkinsfile above never mentions git. If you ever need to disable that automatic checkout, use options { skipDefaultCheckout() }, and to check out again explicitly use the step checkout scm.
The second is a Multibranch Pipeline, which watches a whole repository and creates a child job for every branch (and, with the right plugin, every pull request) that contains a Jenkinsfile. You push a new branch, Jenkins notices, and a job for it appears; you delete the branch and the job goes away. To use it you need a branch-source plugin such as GitHub Branch Source, Bitbucket Branch Source or GitLab Branch Source. For a beginner the single-branch job is enough, and you can move to Multibranch when the team starts working with feature branches. Inside a Multibranch job, extra variables such as BRANCH_NAME and CHANGE_ID tell your script what it is building, and a stage can be limited to pull requests with when { changeRequest() }.
For a local experiment you can point Jenkins at a repository on the same machine, but with Docker the container cannot see your laptop's folders. Use a hosted repository (public repositories on GitHub or GitLab need no credentials) for your first tries.
Polling versus webhooks: how a build starts by itself
So far you clicked Build Now. The real value of CI is that nobody has to click. There are three common triggers.
Polling means Jenkins asks the repository every so often "has anything changed?". It is simple and works everywhere, but it wastes effort and adds delay. A webhook is the reverse: the repository sends Jenkins a message the moment a push happens, so the build starts within seconds and nothing is wasted. For GitHub, the webhook address is your Jenkins URL followed by /github-webhook/. Prefer webhooks whenever your Jenkins is reachable from the hosting provider. A schedule runs the job at fixed times regardless of commits, which suits nightly jobs.
You declare triggers in the Jenkinsfile:
pipeline {
agent any
triggers {
cron('H 2 * * 1-5')
pollSCM('H/15 * * * *')
}
stages {
stage('Build') { steps { echo 'building' } }
}
}
The schedule syntax has five fields: minute, hour, day of the month, month, day of the week. H 2 * * 1-5 means "once between 02:00 and 02:59, on weekdays". The letter H stands for "hash": Jenkins picks a stable, job-specific value so that fifty jobs all scheduled for "2 am" do not all start at the same second and overload the server. Prefer H to a fixed minute such as 0. H/15 * * * * means "every fifteen minutes, at a per-job offset". Aliases such as @daily and @hourly exist for the common cases.
A third trigger, upstream(upstreamProjects: 'job1,job2', threshold: hudson.model.Result.SUCCESS), runs this job when other jobs finish successfully.
triggers block when it runs the pipeline, so a brand-new Jenkinsfile needs one manual build before its schedule is registered.
- Push your small repository with its
Jenkinsfileto a hosting service. - Create a Pipeline script from SCM job pointing at it, and build it.
- Edit the Jenkinsfile, push, and build again. Then add a
pollSCM('H/2 * * * *')trigger, run one manual build, push another change, and wait.
Environment variables, parameters and choices
A pipeline that does exactly the same thing every time is a good start, but real pipelines need input. Jenkins offers three sources: variables Jenkins provides, variables you define, and parameters that a person supplies when they start a build.
Variables Jenkins gives you
Every build has built-in variables. The ones you will use most are BUILD_NUMBER (for example 7), BUILD_ID, BUILD_TAG, BUILD_URL (the address of this build's page), JOB_NAME, JENKINS_URL, NODE_NAME, WORKSPACE (the directory the build is using) and EXECUTOR_NUMBER. Multibranch jobs add BRANCH_NAME, and the Git plugin adds GIT_COMMIT. You can see all of them for your own server by opening http://localhost:8080/pipeline-syntax/globals.
There are two ways to read a variable, and mixing them up is a classic beginner error.
steps {
echo "Build number is ${env.BUILD_NUMBER}"
sh 'echo "Build number is $BUILD_NUMBER"'
}
In the first line, the double-quoted string is Groovy, which reads env.BUILD_NUMBER itself. In the second, the single quotes hand the text to the shell untouched, and the shell expands $BUILD_NUMBER. Both print the same thing, but they work by different mechanisms, and when you write "$FOO" in a Groovy string for a variable that only exists in the shell, you get groovy.lang.MissingPropertyException: No such property: FOO. Rule of thumb: single quotes for shell commands, and only use double quotes when you want Groovy to substitute something.
Parameters
A parameter turns a job into a small application with a form. Declare them in a parameters block:
pipeline {
agent any
parameters {
string(name: 'TARGET_BRANCH', defaultValue: 'main', description: 'Branch to build')
choice(name: 'ENVIRONMENT', choices: ['dev', 'staging'], description: 'Where to deploy')
booleanParam(name: 'RUN_SLOW_TESTS', defaultValue: false, description: 'Include the slow suite')
}
stages {
stage('Show') {
steps {
echo "Branch ${params.TARGET_BRANCH}, environment ${params.ENVIRONMENT}"
echo "Slow tests: ${params.RUN_SLOW_TESTS}"
}
}
}
}
Read parameters as params.NAME. The other types are text (multi-line), and password (hidden, but prefer proper credentials, below). After a first build the job's button changes from Build Now to Build with Parameters, and shows the form.
The catch: parameters do not exist until the pipeline has run once, because Jenkins learns about them by running the parameters block. The first build of a new Jenkinsfile therefore runs with no form, and that first run may fail with a "No such property" error if a stage uses a parameter directly. Run it once, and from then on the form appears.
- Add the
parametersblock above to a job and run it once. - Click Build with Parameters, change the choice and the checkbox, and run again.
- Print
${BUILD_URL}and${env.JOB_NAME}in anechostep, and open the URL that appears in the log.
Credentials: handling secrets safely
Builds need secrets: a token to pull a private repository, a password for a registry, an API key for a cloud. The worst way to provide them is to type them into the Jenkinsfile, because the file is committed and everyone with read access to the repository can see it, forever, in its history. Jenkins has a dedicated store for this, and learning to use it is the most important security habit in this guide.
Credentials are secrets stored encrypted on the controller and referred to by an ID that you choose. To add one, go to Manage Jenkins, then Credentials, pick the System store and the Global domain, and click Add Credentials. You choose a Kind: Secret text for a single token, Username with password, Secret file for something like a kubeconfig, or SSH Username with private key. Give it a short, meaningful ID such as registry-login, because the Jenkinsfile will refer to that ID and never to the secret itself.
The two scopes you will see are Global, usable by jobs, and System, meant for the controller's own use such as agent connections. Use Global for anything a pipeline needs.
In a pipeline there are two ways to use a credential. The compact one is credentials() in the environment block:
pipeline {
agent any
environment {
API_TOKEN = credentials('api-token-id')
REG = credentials('registry-login')
}
stages {
stage('Call the API') {
steps {
sh 'curl -s -H "Authorization: Bearer $API_TOKEN" https://example.com/api/status'
sh 'echo "Logging in as $REG_USR"'
}
}
}
}
For a Secret text credential, API_TOKEN holds the value. For a Username with password credential, Jenkins sets three variables: REG (as user:password), REG_USR and REG_PSW. The second way is withCredentials, which limits the secret to a block, and is the better choice when only one step needs it:
steps {
withCredentials([usernamePassword(credentialsId: 'registry-login',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com'
}
}
Other binding types include string(credentialsId:, variable:) for secret text, file(credentialsId:, variable:) for secret files, and sshUserPrivateKey(credentialsId:, keyFileVariable:, usernameVariable:) for SSH keys. Use the Snippet Generator to fill the form rather than memorising the shape.
If you write the same thing with double quotes, you will see a warning and have created a real leak.
Unsafe
sh "curl -u $USER:$PASS https://..."- Groovy substitutes the secret before the shell runs
- The value becomes part of the process arguments, which other users on the machine can read with
ps - Jenkins warns: a secret was passed using Groovy String interpolation
Safe
sh 'curl -u "$USER:$PASS" https://...'- Single quotes: the shell expands the variable itself
- Jenkins masks the value in the log as
**** - The secret never passes through Groovy string handling
**** in logs, but a transformed value (base64-encoded, split over two lines, reversed) is not recognised and prints in clear. Never echo a secret "just to check". And remember that anyone who can edit a Pipeline that can use a credential can write a step that sends it elsewhere, so credential scope is also an access-control decision.
One more thing to know about the SCM side. When Jenkins clones a Git repository over SSH, it checks the server's host key. If you see Host key verification failed or Permission denied (publickey), the credential selected in the job is wrong or missing, or Jenkins has not been told to trust the host. The trust setting is under Manage Jenkins, Security, Git Host Key Verification Configuration.
- Add a Secret text credential with the ID
demo-secretand the valuehunter2. - In a pipeline, bind it with
withCredentials([string(credentialsId: 'demo-secret', variable: 'S')])and runsh 'echo "length: ${#S}"'(single quotes). - Now try
sh 'echo $S'and then a version with double quotes, and compare the logs.
**** and the double-quoted one prints a warning. You have seen masking and the interpolation trap with your own eyes.
Agents, executors and labels
Everything so far used agent any, and on a fresh install it ran on the built-in node, the controller's own machine. That is fine for learning and wrong for anything shared. A build is arbitrary code written by someone. If it runs on the controller, it runs with the same access to the controller's files as Jenkins itself, including the secrets directory. The standard guidance is to set the built-in node's executors to zero (Manage Jenkins, Nodes, Built-In Node, Configure) and run builds on agents.
There are three ways to get an agent.
- A permanent agent over SSH. The controller connects out to a machine you own using an SSH key. This is the most stable and the recommended style for static machines. It needs the SSH Build Agents plugin and an "SSH Username with private key" credential.
- An inbound agent. The agent connects in to the controller. You create the node in the UI, get a secret, and run
java -jar agent.jar -url ... -secret ... -name ... -webSocketon the machine. The-webSocketoption means no extra port is needed and it works through ordinary HTTPS proxies. - A cloud agent, created on demand (a Kubernetes pod, a cloud virtual machine, a Docker container) and deleted afterwards. This is the modern approach because every build starts from a clean machine.
To create a permanent node, go to Manage Jenkins, Nodes, New Node, name it, choose Permanent Agent, and fill in the Remote root directory (the folder on the agent where workspaces will live), the number of executors, the labels, and the launch method.
Labels connect jobs to nodes. If you give a node the labels linux docker, a pipeline can ask for it:
pipeline {
agent { label 'linux && docker' }
stages {
stage('Where am I') {
steps { sh 'hostname' }
}
}
}
A label expression can use && (and), || (or) and ! (not). If no online node matches, the build sits in the queue with a message such as Waiting for next available executor on ‘linux’ or There are no nodes with the label ‘linux’. That is not a crash; it is Jenkins telling you that nothing can run the work. Check that the agent is online and the label is spelled exactly.
You can also ask for a fresh container as the agent, using the Docker Pipeline plugin (docker-workflow), provided Docker is installed on the node that runs it:
pipeline {
agent {
docker { image 'maven:3.9-eclipse-temurin-21' }
}
stages {
stage('Build') { steps { sh 'mvn -v' } }
}
}
The build runs inside that image, so the tools (here Maven and a JDK) come with it and nothing needs installing on the node. This pattern is very popular, because it is the same idea as the Docker guide, applied to builds: the environment is part of the pipeline. Other agent forms are agent none (declare no global agent and set one per stage), dockerfile (build an image from a Dockerfile in the repository) and kubernetes (run in a pod, covered in the Mid-level guide).
- Set the Built-In Node executors to
0and run anagent anyjob. - Read the queue message, then set it back to
1, or create an agent and run the job there. - If you have Docker on the node, run the
mavencontainer example and read the version output.
Results, test reports and artifacts
A build that only prints text is half a build. The other half is what it leaves behind: test results you can browse, and files you can download.
Build results. A build is SUCCESS if everything passed and FAILURE if a step failed. It is UNSTABLE when it ran to the end but something soft went wrong, such as failing tests that the junit step reported. ABORTED means a person or a timeout stopped it, and NOT_BUILT means it was skipped. Colours follow: blue or green for success, yellow for unstable, red for failure, grey for aborted or not built.
Test reports. Most test tools can emit JUnit-style XML. Point the junit step at those files, and Jenkins shows the number of passed and failed tests, lists failures with their messages, and draws a trend graph across builds. Put it in a post { always { ... } } block as in the earlier Jenkinsfile, so reports are collected even when tests fail.
Artifacts. archiveArtifacts artifacts: 'dist/**', fingerprint: true stores the matching files from the workspace with the build, and they appear as downloads on the build page. Artifacts are for modest outputs that belong with a build. Large binaries and container images should go to a proper artifact repository or registry, not to JENKINS_HOME, which is the same directory that holds your configuration.
Timestamps. Adding timestamps() to options prefixes every log line with a time, which makes slow steps obvious. It needs the Timestamper plugin.
Finally, decide what to do when a step fails but the pipeline should continue. By default the first failure stops the build. post and catchError let you react:
stage('Optional lint') {
steps {
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
sh 'make lint'
}
}
}
Here a failing lint marks the stage red but lets the build go on and finish successful. Use this sparingly; a pipeline that ignores its own failures is worse than none.
- In a repository, make a script that writes a small file
dist/result.txt. - Run it in a stage, then add
archiveArtifacts artifacts: 'dist/**'to apost { success { ... } }block. - Open the finished build page and download the file.
Plugins: what they are and how to treat them
Jenkins' power and its most common headaches both come from plugins. Each is a package (.hpi or .jpi file) that adds a feature, installed from the Update Center. You manage them at Manage Jenkins, Plugins, which has tabs for Updates, Available plugins, Installed plugins and Advanced settings (where you configure an HTTP proxy or a different update site).
The plugins a beginner meets, with the ID you would use to install them in a file:
| Plugin | ID | What it gives you |
|---|---|---|
| Pipeline | workflow-aggregator |
The whole pipeline suite |
| Git | git |
Checking out repositories |
| Credentials Binding | credentials-binding |
withCredentials and credentials() |
| Pipeline Graph View | pipeline-graph-view |
The stage diagram on a build page |
| Workspace Cleanup | ws-cleanup |
The cleanWs step |
| JUnit | junit |
Test result publishing |
| Timestamper | timestamper |
timestamps() in options |
| Docker Pipeline | docker-workflow |
agent { docker { ... } } |
Good habits with plugins begin on day one. Install only what you need, because every plugin is code running with Jenkins' authority and every one is something to keep updated. Read the update list before clicking Download now and install after restart, since an update can change behaviour. Take a backup before you update on a server that matters. And treat security advisories seriously: Jenkins publishes them at jenkins.io/security/advisories/, and a vulnerable plugin is one of the most common ways Jenkins servers are attacked.
A final piece of history matters for upgrades. In LTS 2.580.1, nine plugins that used to be bundled inside jenkins.war are no longer included (among them JAXB, JavaMail API, SSH server and Instance Identity). A normal online server downloads them automatically when they are needed. An offline server must fetch the .hpi files and place them in $JENKINS_HOME/plugins before starting the new version, or some configuration will not load.
- Open Manage Jenkins, Plugins, Installed plugins and search for
pipeline-graph-view. - If it is missing, install it from Available plugins and restart when asked.
- Open a pipeline build page and look for the stage view.
The command line and the REST API
Clicking is fine for learning, but automation needs to talk to Jenkins from scripts. There are two supported ways: the command-line client (a jar you download from your own server) and the HTTP API. Both need you to identify yourself, and the right way is an API token.
Create a token. Click your name in the top right, then Security, then Add new token under API Token. Copy it immediately; Jenkins shows it only once. Since LTS 2.555.1 a token can have an expiration date, which is a good practice for anything unattended. A token is a password equivalent for scripts, so treat it like one, and use a different token for each script so you can revoke one without breaking the others.
Trigger a build with curl:
curl -X POST -u admin:YOUR_TOKEN http://localhost:8080/job/hello-pipeline/build
For a job with parameters use buildWithParameters:
curl -X POST -u admin:YOUR_TOKEN \
http://localhost:8080/job/hello-pipeline/buildWithParameters --data ENVIRONMENT=staging
A successful request returns HTTP 201 and a Location: header pointing at /queue/item/N/. That is the queue entry, not the build; Jenkins starts the build when an executor is free. Jobs inside folders have longer paths: /job/folder/job/name/.
Read information. Add /api/json to almost any Jenkins page address and you get a machine-readable version of it. The tree parameter limits the output to what you need:
curl -s -u admin:YOUR_TOKEN \
'http://localhost:8080/api/json?tree=jobs[name,color,lastBuild[number,result]]'
curl -s -u admin:YOUR_TOKEN http://localhost:8080/job/hello-pipeline/lastBuild/api/json
curl -s -u admin:YOUR_TOKEN http://localhost:8080/job/hello-pipeline/3/consoleText
The last one prints the full console log of build 3 as plain text, which is handy for feeding into other tools.
About the CSRF crumb. Jenkins protects against cross-site request forgery (a malicious web page making your browser send a request), by requiring a special value called a crumb on state-changing requests. When you authenticate with an API token, you do not need one, which is one more reason to use tokens. If you authenticate with a password or a browser session and get HTTP ERROR 403 No valid crumb was included in the request, switch to a token. Never switch CSRF protection off to make the error go away.
The command-line client.
curl -O http://localhost:8080/jnlpJars/jenkins-cli.jar
export JENKINS_USER_ID=admin
export JENKINS_API_TOKEN=YOUR_TOKEN
java -jar jenkins-cli.jar -s http://localhost:8080/ who-am-i
java -jar jenkins-cli.jar -s http://localhost:8080/ build hello-pipeline -f -v
who-am-i confirms your identity. build hello-pipeline -f -v starts the job, waits for it to finish (-f) and streams the console (-v). Running help lists every command. The client connects over WebSocket by default (since 2.391), and also supports -http and -ssh. The old -remoting mode no longer exists; if an old script uses it, rewrite it.
- Create an API token and store it somewhere safe.
- Trigger
hello-pipelinewith curl and note the status code and theLocationheader (add-ito see headers). - Fetch the last build's result with
/lastBuild/api/json?tree=number,result.
SUCCESS. You can now drive Jenkins from any script or another tool.
Configuration, JENKINS_HOME, backups and upgrades
A lot of beginners treat Jenkins as a black box until the day it will not start. A little knowledge of how it stores things removes most of the fear.
What lives in JENKINS_HOME. Open the directory (remember the default locations from the install section) and look around. config.xml is the main configuration. jobs/ has a folder per job with its config.xml and its builds. plugins/ holds installed plugins. secrets/ has the keys that encrypt your credentials. users/ has accounts, and nodes/ has agent definitions. Every setting you change in the interface ends up as XML in this tree.
Backups. Back up config.xml, jobs/, plugins/, secrets/, users/, nodes/ and credentials.xml. You can skip war/, cache/, tools/ and workspaces, since Jenkins recreates them. Two points catch people out. A backup that lacks secrets/ cannot decrypt the credentials it contains, and a backup that includes secrets/ is itself sensitive, so store it encrypted. On a server, filesystem or volume snapshots are the simplest approach; the ThinBackup plugin is another. Test restoring at least once, on a scratch machine.
Keeping disk under control. Use buildDiscarder in every Jenkinsfile. Node monitors mark a machine offline when free space drops below about 1 GiB, with a message such as Disk space is too low. Only 0.9GB left on /var/lib/jenkins.; if you see it, clean old builds and workspaces.
Upgrading safely. Stay on the LTS line, and read the upgrade guide for the version you are moving to (for example jenkins.io/doc/upgrade-guide/2.580/) together with the changelog. The routine is: back up JENKINS_HOME, check that your Java version is still supported, update plugins, then upgrade Jenkins, then check the banners in Manage Jenkins. On Docker, change the image tag and keep the same volume. For graceful restarts, safe-restart in the CLI waits for running builds to finish first, instead of killing them.
Configuration as code. Clicking through settings does not scale and cannot be reviewed. The Configuration as Code plugin (JCasC) lets you write controller settings in a YAML file. You will use it at the Mid-level; for now, know it exists and that a setting changed in the interface may be overwritten by JCasC on the next restart if you use both.
- List your JENKINS_HOME (
docker exec <container> ls /var/jenkins_homeor the path for your install). - Find your job's folder under
jobs/and open itsconfig.xml. - Find the numbered build folders and see how large they are with
du -sh.
Common errors and how to read them
Jenkins errors look intimidating because they are long Java stack traces, but almost all of them reduce to a short list of causes. The method is always the same: read the console from the top, find the first line that says ERROR or shows an exception, and ignore the dozens of lines that follow.
| What you see | What it means | What to do |
|---|---|---|
ERROR: script returned exit code 1 |
Your shell command failed | Read the output above this line. Exit 127 means command not found, 137 means killed (often out of memory), 143 means stopped by a signal or timeout |
No such DSL method 'cleanWs' found among steps |
The plugin providing that step is not installed, or the name is misspelled | Install the plugin; check the name in the Snippet Generator |
WorkflowScript: 12: Expected a step @ line 12, column 9. |
Plain Groovy in a steps block |
Wrap it in script { } |
Missing required section "agent" |
A Declarative script without agent |
Add agent any or another agent |
groovy.lang.MissingPropertyException: No such property: FOO |
A variable that does not exist, or a shell variable in a double-quoted string | Define it, use params.FOO or env.FOO, or use single quotes |
Waiting for next available executor on ‘linux’ |
No online node has that label | Bring an agent online or fix the label |
HTTP ERROR 403 No valid crumb was included in the request |
State-changing request without a crumb | Use an API token |
Permission denied (publickey) or Host key verification failed |
Wrong SSH credential, or untrusted host | Select the right credential and set host key verification |
Scripts not permitted to use method ... |
The sandbox blocked a Groovy call | Use a plugin step or a shell tool instead; do not approve blindly |
java.io.NotSerializableException |
A non-serialisable Groovy object was held across steps | Keep plain data in variables, and read the Mid-level guide on this |
Running with Java 17 ... older than the minimum required version (Java 21) |
Jenkins cannot start on old Java | Install Java 21 and point JENKINS_JAVA_CMD or JAVA_HOME at it |
It appears that your reverse proxy set up is broken. |
The proxy does not pass the right headers, or the Jenkins URL is wrong | Check the Jenkins URL and the X-Forwarded-* headers |
PKIX path building failed |
Jenkins does not trust a certificate, often behind a company proxy | Import the CA into the Java trust store |
Two of these deserve a longer word. The Java message is the classic "I followed an old tutorial" failure: you installed the operating system's default Java, which might be 17 or even 11, and Jenkins refuses it with an exact message listing the supported versions. The fix is to install Java 21 and tell Jenkins which Java to use. On an agent, the equivalent message is UnsupportedClassVersionError ... class file version 65.0, which means the agent's Java is older than the controller's; upgrade it.
And the "Scripts not permitted" message deserves caution. Jenkins runs untrusted pipeline code inside a sandbox that only allows a safe list of operations. When it blocks something, an administrator can approve the call on the In-process Script Approval page, but some approvals (for example anything that reaches the Jenkins internals directly) give away administrator power. Before approving, ask whether a normal step would do the job.
Finished: FAILURE, look at the last stage that ran, and read upward until you reach the first command output that looks wrong. The cause is almost always within thirty lines.
- Deliberately cause three errors in a test job: use
cleanWs()with the plugin absent (or a made-up step), writeecho "$FOO"in a Groovy string, and requestagent { label 'nonexistent' }. - For each, find the first error line in the console and match it to the table.
- Fix each and confirm the build turns green.
Putting it all together
Here is one small project that uses nearly everything above: a Python service with tests, built on an agent, with a timeout, log retention, a parameter, a secret, test reports, an archived artifact and a nightly schedule. Put this in a repository with a tiny app.py and test_app.py, then create a Pipeline script from SCM job.
pipeline {
agent {
docker { image 'python:3.12-slim' }
}
options {
timeout(time: 20, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '15'))
timestamps()
}
triggers {
cron('H 2 * * 1-5')
}
parameters {
booleanParam(name: 'PUBLISH', defaultValue: false, description: 'Upload the package')
}
environment {
HOME = "${WORKSPACE}"
}
stages {
stage('Install') {
steps {
sh 'pip install --user pytest'
}
}
stage('Test') {
steps {
sh '~/.local/bin/pytest --junitxml=reports/results.xml'
}
post {
always {
junit 'reports/results.xml'
}
}
}
stage('Package') {
steps {
sh 'mkdir -p dist && tar czf dist/app-${BUILD_NUMBER}.tgz app.py'
}
}
stage('Publish') {
when { expression { return params.PUBLISH } }
steps {
withCredentials([string(credentialsId: 'package-token', variable: 'TOKEN')]) {
sh 'echo "would upload with a token of length ${#TOKEN}"'
}
}
}
}
post {
success {
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
cleanup {
cleanWs()
}
}
}
Walk through it in order. The agent is a python:3.12-slim container, so nothing has to be installed on the node apart from Docker itself. The environment block sets HOME into the workspace, because the container runs as a user that may have no writable home directory; that is what lets pip install --user work. The three options give you a timeout, bounded history and timestamps. The Test stage publishes reports in always, so a failure still produces a readable report. The Package stage builds a file whose name includes BUILD_NUMBER, making every artifact traceable to a build. The Publish stage only runs when a person ticks the box, through a when condition, and uses a credential the safe way. Finally, post archives the artifact on success and cleans the workspace whatever happened.
Run it three times: once normally, once with the PUBLISH box ticked, and once after you break a test on purpose. Compare the three build pages. The broken run should show a red Test stage, a test report listing the failure, no Package stage and no archived artifact, because the pipeline stopped where it should.
When you are comfortable with this, connect a webhook so every push builds, and then read how the same pipeline would deploy to a cluster in the Kubernetes guide.
- Build the project above from scratch, with a real (tiny) test.
- Run it three times as described: passing, with publish ticked, and with a failing test.
- Change one option and explain in a sentence what the difference was in the console.
What you can now do, and what comes next
You can install Jenkins on the platform of your choice and confirm the version and the Java it runs on. You can explain the controller, agents, executors, jobs, builds and workspaces, and say why the built-in node should run nothing in production. You can write a Declarative Jenkinsfile with stages, options, environment variables, parameters, triggers, post actions, test reports and artifacts. You can keep secrets out of your code and out of your logs, run builds on labelled agents or in containers, and start and inspect jobs from curl and the command-line client. And you can read a red build: find the first real error, classify it, and fix it.
What you cannot do yet, and where the next levels go. The Mid-level guide covers the parts that turn a working pipeline into a good one: parallel stages, when conditions and matrix builds, approval gates, shared libraries so ten repositories do not copy one Jenkinsfile, Kubernetes-based agents, how Pipeline code actually executes (and why some Groovy fails mysteriously), and proper multibranch setups. The Senior guide covers running Jenkins as a platform: securing it with real authorization, isolating teams, scaling, observability, upgrades and backups at scale, and when to choose something else.
Neighbouring guides in this catalogue are the natural next stops. Docker deepens the container skills your pipelines depend on. Kubernetes and Helm cover where many teams deploy to and how they package what they deploy. Terraform is the usual way to create the machines that Jenkins runs on. And Argo CD is what many teams pair with Jenkins: Jenkins builds and tests and pushes an image, and Argo CD handles the deployment by watching a Git repository.
A closing piece of advice. The people who get the most from Jenkins treat the Jenkinsfile as production code: small, reviewed, tested by running, and boring. The people who struggle let pipelines grow into hundreds of lines of clever Groovy, click changes into the interface that nobody records, and install plugins without reading them. Choose to be the first kind from your first build.
Sources
- Jenkins documentation
- Jenkins LTS changelog
- Upgrade guide for 2.580
- Java support policy
- Installing Jenkins on Linux
- Installing Jenkins with Docker
- Installing Jenkins on macOS
- Installing Jenkins on Windows
- Running Jenkins from the WAR file
- Installing Jenkins on Kubernetes
- Pipeline syntax
- Using a Jenkinsfile
- Using credentials
- Using agents
- Managing nodes
- Jenkins CLI
- Remote access API
- CSRF protection
- Script approval
- Blue Ocean status
- Security advisories