What Jenkins actually is, and when it is the wrong tool
Examples validated: August 2026 — Jenkins LTS 2.555.x line · Java 21 · Ubuntu 24.04 · Docker Compose v2
Jenkins core, Java and plugins move on independent schedules, and the supported
combinations change. Before following an installation command, check the
Java support policy
and the LTS upgrade guide for the
line you are actually installing. Anything version-specific in this book is
marked where it appears.
Jenkins is an automation server. Most people meet it as a CI/CD build server, and that is the honest way in: you give it a job — "check out this repository, run these commands, tell me if they failed" — and it runs that job on a schedule, on a trigger, or on demand, and keeps the history.
Nothing about it is limited to building, though. A Jenkins job is "run these steps and record what happened", which is why you also find it driving releases, provisioning, backups and scheduled reports.
That is the whole idea. Everything else — pipelines, agents, plugins, shared libraries — is machinery for doing that reliably at scale.
The mental model
There are exactly four nouns worth memorising early:
Noun
What it is
Controller
The Jenkins process itself. Serves the UI, stores configuration and history, schedules work. Used to be called "master".
Agent
A machine (or container) that actually runs your build. The controller sends work to it.
Job / Item
A definition of work. A Pipeline, a Freestyle project, a Folder, a Multibranch Pipeline.
Build / Run
One execution of a job. Numbered #1, #2, #3… and kept forever unless you tell Jenkins otherwise.
The single most useful sentence about Jenkins architecture: the controller should not run builds. It schedules them. The moment builds run on the controller they can read its secrets, its config, and each other's workspaces. We come back to this in Chapter 6.
What Jenkins is genuinely good at
Running anything — it shells out. If it runs in a terminal it runs in Jenkins.
Environments where the build machines are yours: on-prem, air-gapped, GPUs, Windows and macOS in the same farm.
Situations where you need to bend the tool. The plugin surface and Groovy escape hatch mean almost nothing is impossible.
When to use something else
Be honest about this, because inheriting a Jenkins nobody wanted is a common way to spend a year.
A small project already on GitHub — GitHub Actions is less to own. No server, no upgrades, no plugin drift.
You want ephemeral, container-native builds and nothing else — a purpose-built runner is simpler.
Nobody will own it. Jenkins is a server. Servers need patching, backup, and someone who notices when disk fills. An unowned Jenkins becomes an unpatchable one that everything depends on. This is the single most common failure mode, and it is organisational, not technical.
Vocabulary you will meet immediately
Jenkinsfile — a file in your repository describing the pipeline. Pipeline-as-code.
Declarative vs Scripted — two syntaxes for a Jenkinsfile. Declarative is the structured one you should default to. Chapter 4 covers both.
Executor — one slot for running a build. An agent with 4 executors runs 4 builds at once.
Workspace — the directory on an agent where a job's files live.
Stage — a named phase of a pipeline. Shows up as a column in the UI.
Plugin — Jenkins core is small; nearly every capability including Git support is a plugin.
Takeaway: Jenkins is a general-purpose build server you host yourself. Choose it when you need control over the machines or the flexibility; avoid it when you mostly need convenience and nobody has volunteered to own a server.
Running Jenkins in Docker for a throwaway lab
Requires — On the agent: docker
For learning, testing an upgrade, or reproducing a bug, a container beats a VM. You can throw it away and start clean in seconds.
docker compose up -d
docker compose logs -f jenkins
The Docker-in-Jenkins problem
You will quickly want your pipelines to build container images. The container does not have a Docker daemon inside it, so docker build fails with something like Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Understand what you just did. Access to the Docker socket is equivalent to root on the host — anyone who can run a build can mount / into a privileged container and own the machine. In a lab that is fine. In anything shared, it is not. The real answer is running builds on agents that are themselves disposable, which is Chapter 6.
Useful lab commands
# shell inside the controller
docker exec -it jenkins bash
# tail the application log
docker logs -f jenkins
# start completely fresh
docker compose down -v && docker compose up -d
# pin a version so an upgrade cannot surprise you
docker run ... jenkins/jenkins:2.555.3-lts-jdk21
Takeaway: containers are the right way to run a Jenkins lab. Always use a named volume, and treat mounting the Docker socket as giving builds root on the host.
agent any — for this first pipeline, let Jenkins pick any suitable executor. A top-level agent directive is required, but any is only one of its forms; Chapter 4 covers agent none with per-stage agents.
stages { } — container for the phases.
stage('Name') { } — one phase. The name appears in the UI.
steps { } — what actually runs.
Moving it into the repository
Commit this as Jenkinsfile at the root of your repo, then change the job: Configure → Pipeline → Definition: Pipeline script from SCM.
SCM: Git
Repository URL: your repo
Credentials: pick or add (Chapter 7)
Branch Specifier: */main
Script Path: Jenkinsfile
Now the pipeline is versioned, reviewable, and travels with the code.
Note that with SCM definition Jenkins does an implicit checkout of the repo containing the Jenkinsfile before running it — you do not need an explicit checkout scm for that first clone in most setups, though being explicit does no harm.
Everything here is covered properly in Chapter 4. What matters now is the shape: options, environment, stages, post.
Two features you get for free
Open a completed build:
Replay — edit the script and re-run without committing. Iterate on a broken pipeline in seconds instead of push-wait-push-wait.
Restart from Stage — re-run from a chosen stage. If deploy failed, restart at deploy rather than rebuilding for fifteen minutes.
Neither exists for Freestyle. They are reason enough on their own.
Takeaway: create a Pipeline job, get it green in the inline box, then move the Jenkinsfile into the repo. Learn Replay immediately — it changes how fast you can iterate.
Reading a failing build — a repeatable method
Requires — Plugin: Workspace Cleanup · On the machine you run this from: jq
Most Jenkins debugging is the same five moves in the same order. Learning them turns "the build is red" from a research project into two minutes.
1. Which stage?
Open the job. Look at Stage View. The red cell names the stage. If the whole row is grey after a point, the pipeline died before reaching those stages.
2. Which step?
Open the build → Pipeline Steps. A tree of every step with its status. Click the failed one and you get only its output — enormously better than scrolling a 40,000-line console.
3. What did it actually say?
Console Output. Jump to the end; failures are usually near the bottom. For a long log:
Your command failed. The real error is above this line.
No such DSL method 'xyz'
A step that does not exist — usually a missing plugin, or Scripted syntax in a Declarative block.
there are no nodes with the label 'x'
Label mismatch. No agent matches agent { label 'x' }.
java.io.NotSerializableException
Non-serialisable object held across a step boundary. Chapter 9.
Scripts not permitted to use method …
Groovy sandbox rejection. Needs approval in In-process Script Approval.
Cannot run program "docker"
The tool is not installed on the agent that ran the build.
hudson.plugins.git.GitException
Checkout failed — usually credentials or a wrong branch specifier.
The most common mistake in reading Jenkins logs is stopping at script returned exit code 1. That is Jenkins reporting the exit status. Your actual error — the compiler message, the failing assertion — is above it.
4. Which commit?
Changes on the build page. If the previous build was green and this one is red, the answer is in that list. Compare with the last successful build's number.
5. Is it the code or the machine?
Two checks that separate them:
Does it fail on a different agent? Re-run pinned elsewhere.
Does it fail with a clean workspace? Add cleanWs() or wipe it via Job → Workspace → Wipe Out Current Workspace.
If it passes clean, you have workspace contamination — stale build output, a cached dependency, a leftover lockfile.
If it passes on another agent, you have machine drift — a different tool version, missing package, full disk.
If the pipeline logic itself is suspect, Replay the build, edit the script — add sh 'env | sort', add sh 'ls -la' — and run again. No commit, no push, no waiting for a webhook.
Takeaway: Stage View → Pipeline Steps → console (read above the exit code) → Changes → try another agent and a clean workspace. That order solves most failures without guessing.
First run checklist — what to set before anyone else logs in
A fresh Jenkins is wide open in several small ways. Fix them in the first ten minutes, before it grows users and jobs.
1. Set the Jenkins URL
Manage Jenkins → System → Jenkins Location
Set Jenkins URL to the real external address, with scheme and port: https://ci.example.com/. Set the System Admin e-mail address too — notification mail without it fails silently on many SMTP servers.
Getting the URL wrong breaks: webhook callbacks, links in emails, the agent connection URL, and anything using ${BUILD_URL}.
This is the single most valuable security change on the list. With executors on the controller, any job can read /var/lib/jenkins/secrets, every credential, and every other job's workspace. Set it to zero and give yourself an agent (Chapter 6).
On a lab where you have no agent yet, leave it at 1 and remember it is a lab.
3. Check the security realm and authorisation
Manage Jenkins → Security
Security Realm — "Jenkins' own user database" is fine to start. Uncheck Allow users to sign up unless you genuinely want open registration.
Authorization — the wizard leaves this at "Logged-in users can do anything". For anything shared, move to Matrix-based security or install Role-based Authorization Strategy. Chapter 7 covers this properly.
Enable CSRF Protection should be on. It is by default on modern versions; verify it.
4. Set a build retention policy now
Build history grows without limit and fills the disk. It is much easier to set this before you have 40 jobs.
numToKeepStr keeps the metadata and logs; artifactNumToKeepStr keeps the (much larger) archived artifacts for fewer builds. Keeping 30 logs and 5 sets of artifacts is a sane default.
5. Configure the tools you will use
Manage Jenkins → Tools
Declare JDKs, Maven, Gradle, NodeJS installations here and pipelines can request them by name rather than depending on whatever is on the agent's PATH. Prefer installing tools on agents (or using container images) over Jenkins' auto-installers, which download on every new agent.
6. Time zone and appearance
Manage Jenkins → Appearance lets you set a theme. More usefully, add a system message identifying the instance — "PRODUCTION CI — changes require a ticket" saves somebody a bad afternoon.
The ten-minute checklist
[ ] Jenkins URL set to the real external URL
[ ] Admin email set
[ ] Controller executors set to 0 (or acknowledged as a lab)
[ ] Sign-up disabled
[ ] Authorization strategy chosen deliberately
[ ] CSRF protection confirmed on
[ ] A build retention default agreed
[ ] Admin account created; initial password file no longer the way in
[ ] JENKINS_HOME backup exists and has been restored once
That last one is the only item people skip and later regret. A backup you have never restored is a hypothesis.
Takeaway: the two settings that matter most on day one are the Jenkins URL and setting controller executors to zero. Everything else can be tuned later; those two cause confusing pain if left wrong.
Finding Your Way Around Jenkins
The dashboard, and reading it quickly
The landing page at / is the job list. It is dense and old-fashioned, and once you can read it you can diagnose most things without clicking.
The columns
Column
Meaning
S (status)
The result of the last completed build.
W (weather)
A trend across recent builds — sun through storm. Storm means most recent builds failed.
Name
The job. Click to enter it.
Last Success / Last Failure
How long ago, with a link to that build.
Last Duration
How long the last build took. Sudden growth here is a real signal.
Build button (▶)
Trigger immediately. If the job takes parameters this opens a form instead.
The S column colours are worth knowing exactly:
Blue — success. (Jenkins uses blue, not green. There is a plugin to change it, and arguments about it going back a decade.)
Red — failure.
Yellow — unstable. The build ran but something like a test reporter marked it degraded.
Grey — never built, or aborted.
Flashing/animated — currently running.
Unstable versus failed matters. Failed means the command returned non-zero. Unstable usually means the build succeeded but tests failed and a publisher downgraded it. Chapter 8 covers making that distinction deliberately.
The left sidebar
New Item — create a job. The most-used link on the page.
People — users known to Jenkins, including ones seen only in commit metadata.
Build History — every build across every job, on a timeline.
Manage Jenkins — all administration. Covered below.
My Views — personal dashboards.
The build queue and executor status
Bottom-left, and the first place to look when "Jenkins is stuck":
Build Queue — jobs waiting. Hover the ⓘ for why an item is queued: no available executor, waiting for a lock, or "there are no nodes with the label X" — which is the answer to most mysteries.
Build Executor Status — every executor across all agents, and what it is running. An idle grid plus a full queue means a label mismatch, not a capacity problem.
Views: the first thing to do once you have 20 jobs
The flat list stops working quickly. Create a view: the + tab next to "All".
List View — pick jobs explicitly or by a regex on the name. ^payments-.* gathers a team's jobs.
My View — everything you have permission to see, automatically.
List View options worth setting: Add column for things like Last Stable, and Recurse in subfolders if you use folders — without it a view silently ignores every job inside a folder.
Folders
Install Folders (usually already present) and New Item → Folder. Folders give you namespacing, and — more importantly — a scope for credentials and permissions. Credentials defined in a folder are visible only to jobs inside it, which is how you stop one team's pipeline reading another team's deploy key.
Takeaway: learn the S/W columns and the queue tooltip. "Nothing is building" is almost always a label mismatch, and the queue tooltip says so in plain English.
Inside a job — every tab on the job and build pages
Click a job and you land on the job page. This is where you will spend most of your time.
The job page
Left sidebar:
Status — where you are. Recent builds and the trend graph.
Changes — commits included in each build, aggregated. Excellent for "when did this break and who touched it".
Workspace — browse the files on the agent as the last build left them. Only present for jobs with a workspace, and only until it is wiped.
Build Now / Build with Parameters — trigger.
Configure — edit the job. For a Pipeline job this is where the Jenkinsfile path or inline script lives.
Delete — with a confirmation.
Rename, Move (into a folder)
Pipeline Syntax — the snippet generator. See its own note; it is the most under-used page in Jenkins.
Main area:
Stage View — a grid of stages across recent builds with per-stage timings. Red cell tells you exactly which stage failed, and the timing column tells you what got slow.
Build History — down the left, oldest at the bottom. Each entry links to a run.
Permalinks — "Last build", "Last stable build", "Last successful build". These are stable URLs you can reference from scripts or dashboards.
The build page
Click a build number:
Console Output — the log. The one you want 90% of the time.
Edit Build Information — rename the build or add a description. Underused: a build description like "release candidate 2.1" makes history readable months later.
Changes — commits in this specific run.
Workspace — as above.
Restart from Stage — with Declarative Pipeline, re-run from a chosen stage using the same commit. Saves enormous time on a long pipeline whose deploy step failed.
Replay — re-run this build with an edited pipeline script, without committing. The best debugging feature in Jenkins; see Chapter 4.
Pipeline Steps — every step executed, in a tree, each linking to its own log fragment. Where you look when the console is 40,000 lines.
Search, keyboard shortcuts and getting around fast
Requires — On the machine you run this from: jq
Jenkins has navigation affordances that almost nobody uses because they are not advertised.
The search box
Top right of every page. It is not full-text search — it matches job names, view names, users and build numbers, and it understands paths.
Type a job name and press Enter and you land on the job. More usefully it accepts commands:
api-build → the job
api-build 142 → build #142 of that job
api-build lastBuild
URL-driven navigation
Faster than clicking, once the shapes are in your fingers:
/job/<name>/ job page
/job/<folder>/job/<name>/ job inside a folder — note the repeated /job/
/job/<name>/configure straight to configuration
/job/<name>/<n>/console console of build n
/job/<name>/build?delay=0sec trigger (POST; needs a crumb)
/view/<view-name>/ a view
/user/<username>/ a user's page
The repeated /job/ for folders trips everyone up. A job web inside folder team-a is /job/team-a/job/web/, not /job/team-a/web/.
Keyboard
Classic Jenkins has few shortcuts, but recent versions added a command palette. Press ? on any page to see what the version in front of you supports. On modern releases, Ctrl/Cmd + K opens a search palette.
The API is navigation too
Every page has a machine-readable twin — append :
Making Jenkins yours — user settings and personal views
A shared Jenkins with hundreds of jobs is unusable until you make it yours. These take five minutes and pay back daily.
Your user page
Click your name (top right) → Configure, or /user/<you>/configure:
Full Name — what appears against builds you trigger.
Email — where notifications go. Set it, or culprits()-style notification silently skips you.
API Token — for scripts and the CLI. Add one per purpose (laptop, deploy-script) so you can revoke individually.
SSH Public Keys — for the SSH-based CLI, if enabled.
My Views — which view you land on.
Notification preferences — added by whichever notification plugins are installed.
Personal views
Dashboard → My Views → +
A view visible only to you, listing only what you care about. This is the single best quality-of-life change on a large instance because it changes nothing for anyone else.
Two useful kinds:
List View with a regular expression on the job name — ^(payments|billing)-.* catches your team's jobs and keeps catching new ones automatically.
My View — everything you have permission to see, no configuration.
Options worth setting on a List View:
Recurse in subfolders — without it the view silently ignores everything inside folders, which on a folder-organised Jenkins means it shows nothing.
Add column → Last Duration, Last Failure, Build Button. The build button in the list saves a navigation every time.
Filter build queue / Filter build executors — narrows the sidebar to this view's jobs. On a busy instance this makes the queue readable.
Shared views
The + tab next to All on the main dashboard creates a view for everyone. Useful for a team dashboard on a wall display. Requires View/Create.
For a screen in the room, combine a shared view with the plugin, which renders a full-screen grid of job status readable from across an office.
Pipeline visualisation, and the Blue Ocean situation
There are three ways to look at a pipeline run. One of them is on its way out, and you will still meet it on existing installations.
Stage View — always there
The grid on the job page, provided by Pipeline: Stage View. A column per stage, a row per build, with per-stage timings. No installation decision to make; it is part of the standard set.
Good for: spotting which stage broke and what got slower. Poor at parallel branches, which it flattens.
Pipeline Graph View — the one to use now
Install Pipeline Graph View. It draws the run as a real graph, with parallel branches as actual branches, and gives per-stage log panes.
This is the plugin the Jenkins project now points people at. It is actively maintained and it covers the part of Blue Ocean that people actually valued — reading a complex pipeline at a glance.
Blue Ocean — legacy
Blue Ocean was an alternative UI, and for years the standard recommendation for pipeline visualisation. That has changed:
Jenkins' documentation states Blue Ocean will be deprecated in July 2026 and will not receive further security fixes or functionality updates, and directs users to Pipeline Graph View and Stage View instead.
Read that second clause carefully. "No further security fixes" on a plugin that renders build output into your Jenkins origin is not a neutral status.
What to do:
New installations — do not install it. Use Pipeline Graph View.
Existing installations — you will find it at /blue. Do not build workflows, bookmarks or documentation around it, and plan its removal.
If you keep it — understand you are running an unmaintained plugin in a privileged application, and weigh that accordingly.
It never covered administration anyway: no Manage Jenkins, no credentials, no agent management. You always bounced back to classic Jenkins, which is part of why replacing it costs so little.
Declarative Pipeline is a strict structure. Everything has a place, and putting something in the wrong place is a compile error rather than a mystery at runtime. That strictness is the point.
The directives must appear roughly in the order above — agent first, stages last before post. Jenkins tells you if you get it wrong, but knowing the order saves the round trip.
Environment variables. Also where credentials get bound.
parameters
Build-time input.
triggers
Automatic starting.
tools
Tool installations to put on PATH.
stages
The work. One or more stage.
post
Cleanup and notification, by result.
agent in its forms
agent any // any available executor
agent none // none at top level; each stage declares its own
agent { label 'linux && docker' } // label expression
agent { node { label 'linux'; customWorkspace '/mnt/fast/ws' } }
agent {
docker {
image 'node:20-alpine'
args '-v /tmp/cache:/cache'
label 'docker'
}
}
agent {
dockerfile {
filename 'Dockerfile.ci'
dir 'build'
additionalBuildArgs '--build-arg VERSION=1.2'
}
}
agent {
kubernetes {
yamlFile 'build-pod.yaml'
}
}
agent none at the top with per-stage agents is the pattern for a pipeline whose stages need different machines. Be aware that with agent none there is no shared workspace between stages — you must stash/unstash to move files. Chapter 6.
options worth knowing
options {
timeout(time: 30, unit: 'MINUTES') // whole pipeline
retry(2) // retry the whole pipeline
timestamps() // prefix every log line
disableConcurrentBuilds() // one at a time
disableConcurrentBuilds(abortPrevious: true) // newest wins
skipDefaultCheckout() // do not auto-checkout
skipStagesAfterUnstable()
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '5'))
parallelsAlwaysFailFast()
quietPeriod(15)
preserveStashes(buildCount: 5)
}
disableConcurrentBuilds(abortPrevious: true) is the one to reach for on pull-request pipelines: when someone pushes three times in a minute, only the last build matters and the other two are wasted agent time.
credentials() in an environment block makes secrets available as variables. For a username/password credential it creates three: NAME, NAME_USR, NAME_PSW. Chapter 7 covers this and its pitfalls.
Stage-level environment blocks override the global one for that stage only.
Takeaway: learn the skeleton by heart. Declarative's rigidity means a misplaced block fails fast with a clear message, which is much better than the alternative.
Stages, parallel execution and matrix builds
Stages carve a pipeline into readable phases. Parallel and matrix are how you stop it taking an hour.
Sequential stages
stages {
stage('Build') { steps { sh 'make build' } }
stage('Test') { steps { sh 'make test' } }
stage('Deploy') { steps { sh 'make deploy' } }
}
Or globally with parallelsAlwaysFailFast() in options. Without it, a failing branch lets the others run to completion — which you sometimes want (to see all the failures) and sometimes do not (to free the agents).
Matrix
Matrix generates the parallel branches for you across combinations.
when — running a stage only sometimes
Requires — Plugin: Docker Pipeline
when decides whether a stage runs. It is evaluated when the pipeline reaches that stage.
The conditions
when { branch 'main' }
when { branch pattern: 'release/.*', comparator: 'REGEXP' }
when { buildingTag() }
when { tag 'v*' }
when { changeRequest() } // any PR
when { changeRequest target: 'main' } // PRs targeting main
when { environment name: 'DEPLOY', value: 'true' }
when { equals expected: 2, actual: currentBuild.number }
when { expression { params.DEPLOY == true } } // arbitrary Groovy returning a boolean
when { changeset '**/*.js' } // only if matching files changed
when { changelog '.*\\[deploy\\].*' } // commit message matches
when { triggeredBy 'TimerTrigger' }
when { not { branch 'main' } }
when { allOf { branch 'main'; environment name: 'ENV', value: 'prod' } }
when { anyOf { branch 'main'; branch 'develop' } }
The combinators
when {
allOf {
branch 'main'
not { changeRequest() }
anyOf {
changeset '**/*.go'
changeset '**/go.mod'
}
}
}
beforeAgent — the one that saves money
post blocks and notifications people actually read
post runs after a stage or the whole pipeline, keyed on result. It is where cleanup and notification belong.
The conditions
post {
always { echo 'runs no matter what' }
success { echo 'only if SUCCESS' }
failure { echo 'only if FAILURE' }
unstable { echo 'only if UNSTABLE' }
aborted { echo 'manually stopped or timed out' }
unsuccessful { echo 'failure OR unstable OR aborted' }
changed { echo 'result differs from the previous build' }
fixed { echo 'previous failed, this one passed' }
regression { echo 'previous passed, this one did not' }
cleanup { echo 'always, and last of all' }
}
Publishing test results in post { always { … } } matters: if the test command exits non-zero the block stops, but still runs — so you still get the report telling you test failed.
Parameters — making a job take input
Requires — Plugin: Active Choices · On the machine you run this from: jq
Parameters turn a job into something you can drive: which branch, which environment, whether to skip tests.
Declaring them
pipeline {
agent any
parameters {
string(
name: 'BRANCH',
defaultValue: 'main',
description: 'Branch to build'
)
choice(
name: 'ENVIRONMENT',
choices: ['dev', 'staging', 'production'],
description: 'Where to deploy'
)
booleanParam(
name: 'RUN_TESTS',
defaultValue: true,
description: 'Uncheck to skip the test stage'
)
text(
name: 'RELEASE_NOTES',
defaultValue: '',
description: 'Notes for the changelog'
)
password(
name: 'OVERRIDE_TOKEN',
defaultValue: '',
description: 'Only for break-glass runs'
)
}
stages {
stage('Show') {
steps {
echo "branch=${params.BRANCH} env=${params.ENVIRONMENT}"
}
}
stage('Test') {
when { expression { params.RUN_TESTS } }
steps { sh 'make test' }
}
}
}
Read them as params.NAME. They are also injected as environment variables, but params. is explicit and does not collide.
Replay, restart, and iterating without commits
Requires — Locally: Java + jenkins-cli.jar
Two features turn pipeline development from painful into quick. Both are on the build page and both are easy to miss.
Replay
Open any completed build → Replay in the left sidebar.
You get an editable copy of the Jenkinsfile that ran. Change it, click Run, and a new build starts with your edited script — without committing anything.
The normal loop is edit → commit → push → wait for webhook → wait for build → find the typo. Replay collapses that to edit → run.
Things to know:
The replayed build uses the same commit. Only the script differs.
Shared library code is editable too — Replay shows a box per library file. This is the fastest way to develop a shared library.
The replayed script is stored with that build, so you can see what ran.
Copy your change back into the real Jenkinsfile and commit it. Replayed scripts are not persisted to your repo; forgetting this is a classic way to lose twenty minutes of work.
Restart from Stage
Open a completed build → Restart from Stage → choose a stage. Re-runs from there, same commit, same pipeline.
If a fifteen-minute build failed in the two-minute deploy stage because a token expired, restart at deploy.
Caveats:
Declarative only.
The workspace may have been cleaned or the agent may be gone. Restarting a stage that depends on earlier build output fails unless you stash. This is the main reason stash/unstash earns its place even in single-agent pipelines.
Keep stashes around for restarts:
options {
preserveStashes(buildCount: 5)
}
Replaying from the command line
The snippet generator and Pipeline Syntax page
Requires — On the machine you run this from: jq
Nobody memorises Jenkins step parameters. There are hundreds of steps, each contributed by a plugin, each with its own options. The Pipeline Syntax page generates the code for you.
Getting there
From any Pipeline job: left sidebar → Pipeline Syntax. Or directly at /job/<name>/pipeline-syntax/.
Snippet Generator
Pick a step from the dropdown, fill in the form, click Generate Pipeline Script, and it emits the exact Groovy.
This is the correct way to write anything non-obvious. Example — a git checkout with credentials, generated:
Shallow clone with depth: 1 on a large repository is often the single biggest build-time win available. You would not find that syntax by guessing.
The other tabs
Declarative Directive Generator — the same idea for directives rather than steps: agent, options, when, triggers. Very useful for conditions, whose syntax is fiddly.
Source Control, Triggers and Webhooks
Checkout, and the options that matter on a big repository
Every pipeline starts by getting the code. On a small repo any approach works; on a large one, checkout is often the slowest stage and the options below are where the time goes.
The three ways
// 1. Implicit — with "Pipeline script from SCM", Jenkins checks out to find
// the Jenkinsfile. `checkout scm` repeats that same configuration.
checkout scm
// 2. The git step — simple, limited
git branch: 'main',
credentialsId: 'github-deploy-key',
url: 'git@github.com:acme/api.git'
// 3. The full checkout step — everything
checkout([
$class: 'GitSCM',
branches: [[name: '*/main']],
userRemoteConfigs: [[
credentialsId: 'github-deploy-key',
url: 'git@github.com:acme/api.git'
]],
extensions: []
])
Use checkout scm in a Multibranch pipeline — it checks out the right branch or PR automatically. Use the explicit form when you need a second repository or non-default options.
The extensions that matter
extensions: [
// Shallow clone — usually the single biggest win
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30],
// Only fetch the branch you need
[$class: 'CloneOption', honorRefspec: true],
// Start from a clean tree — slower, but removes a class of ghost failures
[$class: 'CleanBeforeCheckout'],
[$class: 'CleanCheckout'],
// Submodules
[$class: 'SubmoduleOption',
recursiveSubmodules: true,
parentCredentials: true,
shallow: true,
depth: 1],
// Check out into a subdirectory — essential when using two repos
[$class: 'RelativeTargetDirectory', relativeTargetDir: 'app'],
// Sparse checkout — only some paths, huge win in a monorepo
[$class: 'SparseCheckoutPaths',
sparseCheckoutPaths: [[path: 'services/api/'], [path: 'shared/']]],
// Keep a local reference clone to avoid re-downloading history
[$class: 'CloneOption', reference: '/var/cache/git/api.git']
]
shallow: true, depth: 1 on a repository with ten years of history can turn a four-minute checkout into fifteen seconds. The catch: shallow clones break anything that needs history — git describe --tags, changelog generation, git diff origin/main...HEAD. If you need history, use depth: 50 rather than full.
parentCredentials: true on submodules is the fix for "submodule checkout works locally but fails in Jenkins" — without it Jenkins does not reuse your credentials for the submodule URLs.
reference: points at a bare clone kept on the agent. New workspaces borrow objects from it instead of re-downloading. On a large monorepo with many jobs this is transformative:
# on each agent, once, kept fresh by a nightly job
sudo git clone --mirror git@github.com:acme/monorepo.git /var/cache/git/monorepo.git
sudo git -C /var/cache/git/monorepo.git remote update --prune
Takeaway: shallow clone and reference repositories are where checkout time goes. Use dir() for multiple repos, and remember shallow clones break git describe and three-dot diffs.
Polling asks "has anything changed?" every few minutes. A webhook is the repository telling Jenkins the moment something does. Webhooks are faster, cheaper, and the only sane choice at scale.
GitHub → Jenkins
On the Jenkins side:
Install GitHub plugin (part of the suggested set). Then Manage Jenkins → System → GitHub → Add GitHub Server, with a credential holding a personal access token if you want Jenkins to manage hooks itself.
Confirm Jenkins URL is correct — GitHub calls back to it.
On the GitHub side:
Repository → Settings → Webhooks → Add webhook:
Payload URL: https://ci.example.com/github-webhook/ — the trailing slash matters.
Content type: application/json
Secret: a shared secret (strongly recommended)
Events: Just the push event, plus Pull requests if you build PRs.
For a Multibranch job the endpoint is the same; Jenkins routes by repository URL.
On the job side:
Freestyle / single Pipeline: tick GitHub hook trigger for GITScm polling.
Multibranch: nothing to tick — the folder listens automatically.
Multibranch Pipeline — a job per branch, automatically
Requires — Plugin: Pipeline: Multibranch (+ a branch source such as GitHub)
A Multibranch Pipeline scans a repository, finds every branch containing a Jenkinsfile, and creates a job for each. Branches appear when created and disappear when deleted.
This is how you should run CI for a real repository. It is not an advanced feature; it is the default.
Creating one
Dashboard → New Item → name → Multibranch Pipeline → OK.
Discover branches — all, or only those that are not PRs
Discover pull requests from origin — merge or head strategy
Discover pull requests from forks — with a trust policy
Filter by name (with wildcards) — e.g. include main release/* PR-*
Clean before checkout, Advanced clone behaviours (shallow, reference)
Build Configuration → Mode: by Jenkinsfile, Script Path: Jenkinsfile.
Scan Multibranch Pipeline Triggers → tick Periodically if not otherwise run, interval 1 day. This is a safety net; webhooks do the real work.
Orphaned Item Strategy → keep 10 old branches for 7 days, so a deleted branch's history does not vanish instantly.
What you get
The job becomes a folder. Inside it, one job per branch and per PR:
/job/api/ the multibranch folder
/job/api/job/main/ the main branch
/job/api/job/PR-431/ a pull request
/job/api/job/release%2F2.1/ a branch with a slash — URL-encoded
Tags, releases and building from a tag
Requires — Plugin: Pipeline: Multibranch (+ a branch source such as GitHub) · Plugin: SSH Build Agents · On the agent: gh (GitHub CLI)
Tag-driven releases are the cleanest release trigger: you tag a commit, CI builds and publishes that exact thing.
Discovering tags in Multibranch
In the Multibranch job: Branch Sources → Behaviours → Add → Discover tags.
Tags then appear as jobs alongside branches. Combine with a filter so you do not build every historical tag:
Add → Filter by name (with wildcards) → Include: main release/* v*
Reacting to a tag in the pipeline
stage('Release') {
when {
beforeAgent true
buildingTag()
}
steps {
sh 'make release'
}
}
stage('Release semver only') {
when { tag pattern: 'v\\d+\\.\\d+\\.\\d+', comparator: 'REGEXP' }
steps { sh "make publish VERSION=${env.TAG_NAME}" }
}
env.TAG_NAME holds the tag. buildingTag() is true for any tag build.
MINUTE HOUR DAY_OF_MONTH MONTH DAY_OF_WEEK
0-59 0-23 1-31 1-12 0-7 (0 and 7 are Sunday)
H — the part that matters
H means "hash" — Jenkins picks a value deterministically from the job name, and that job always gets the same value.
cron('0 2 * * *') // every job with this line fires at exactly 02:00
cron('H 2 * * *') // each job fires at its own fixed minute between 02:00 and 02:59
The first version is how you build a thundering herd: fifty jobs all starting on the stroke of the hour, queueing behind each other, all finishing late. Use H for the minute field, always. There is essentially no case where you want fifty jobs to start in the same second.
H works in ranges too:
Triggering jobs from other jobs
Requires — Plugin: Copy Artifact
Real pipelines chain: build, then test, then deploy — often as separate jobs owned by different teams.
The three-dot form origin/main...HEAD diffs against the merge base, which is what you want — it ignores changes that landed on main since you branched.
This needs history, so combine with depth: 50 rather than depth: 1, and make sure origin/main is actually fetched:
sh 'git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main'
Building a dynamic matrix
Compute the affected services, then generate parallel branches:
Agents and Distributed Builds
Why builds must not run on the controller
The controller holds every credential, every job configuration, and the master encryption key. A build running on it can read all of that.
What a build on the controller can do
If a job has a shell step and runs on the built-in node:
cat /var/lib/jenkins/secrets/master.key
cat /var/lib/jenkins/credentials.xml
ls /var/lib/jenkins/jobs/*/config.xml
That is every secret in your organisation's deployment path, readable by anyone who can edit any job — or by anyone who can open a pull request, if you build PRs.
It is not a subtle privilege escalation. It is the whole store, in plain reach.
The fix
Manage Jenkins → Nodes → Built-In Node → Configure → Number of executors: 0
With zero executors, a pipeline with agent any and no other node simply queues forever — which is the correct, loud failure. Add an agent before you do this, or do both in one sitting.
The other reasons
Beyond security:
Stability. A build that exhausts memory or fills the disk takes Jenkins down with it. On an agent it takes down one build.
Scaling. The controller is a single JVM. Builds compete with the UI, and the UI loses.
Cleanliness. Build tools, compilers and language runtimes accumulate on the controller and become an upgrade problem.
The label discipline that follows
Once builds are on agents, agent any becomes a liability — it means "whichever machine happens to be free", which is how a build that needs Docker lands on a machine without it.
Give agents labels describing capabilities, and ask for capabilities:
With -webSocket you do not need port 50000 open at all — worth doing for that reason alone. It does require the reverse proxy to forward Upgrade/Connection headers (Chapter 1).
Enable the protocol at Manage Jenkins → Security → Agents — set the TCP port to Disable if everything uses WebSocket.
Running it as a service
Kubernetes agents — a pod per build
Requires — Plugin: Kubernetes · On the agent: kubectl + cluster access
The Kubernetes plugin creates a pod for each build and deletes it afterwards. Every build gets a clean, isolated environment, and capacity scales with the cluster.
Setup
Install Kubernetes plugin. Then Manage Jenkins → Clouds → New cloud → Kubernetes:
Kubernetes URL — blank if Jenkins runs in the cluster (it uses the service account).
Kubernetes Namespace — e.g. jenkins-agents.
Jenkins URL — reachable from inside the cluster, e.g. http://jenkins.jenkins.svc.cluster.local:8080. Getting this wrong is the usual reason pods start and never connect.
Test Connection — do this before anything else.
A pod template in the pipeline
Defining the pod in the repository is far better than in the UI:
allowEmpty: false (the default) fails the build if the pattern matched nothing — usually what you want, because an empty stash produces a confusing failure two stages later instead of here.
What stash is not
Stash is not an artifact store. Stashes:
Keeping agents healthy
Requires — Plugin: Workspace Cleanup · On the agent: docker · On the machine you run this from: jq
Agents fail in boring, predictable ways. Most outages are disk, and most of the rest are drift.
Set a disk threshold so Jenkins stops scheduling onto a full agent rather than failing builds on it. Manage Jenkins → Nodes → (agent) → Configure → Node Properties → Disk space monitoring thresholds, or globally under Manage Jenkins → System → Node Monitoring.
Prune Docker regularly. This is usually the real disk consumer:
# a cron job on each Docker-capable agent
docker system prune -af --filter "until=168h"
docker volume prune -f
A cleanup job is the pragmatic answer for workspaces:
Credentials and Securing Jenkins
The credential store — kinds, scopes and ids
Jenkins has a built-in secret store. Everything else in this chapter depends on using it properly.
Where
Manage Jenkins → Credentials. You will see a tree:
Stores scoped to Jenkins
└── System
└── Global credentials (unrestricted)
Stores scoped to <folder>
└── Folder
└── <folder> credentials
Add Credentials on any store.
The kinds
Kind
Use for
Username with password
Registries, artifact repos, basic auth
SSH Username with private key
Git over SSH, agent connections
Secret text
API tokens, webhook secrets
Secret file
kubeconfig, service-account JSON, keystores
Certificate
Client certificates (PKCS#12)
GitHub App
Preferred over a PAT for GitHub — short-lived tokens, finer scope
Set the ID yourself
The ID field is optional and defaults to a random UUID. Always set it, to something descriptive and stable:
A UUID in a Jenkinsfile is unreadable, and it makes the pipeline impossible to run against a second Jenkins.
Scope
Global — usable by any job and by the controller itself.
System — usable only by Jenkins itself (agent connections), not by jobs. Use this for agent SSH keys so no build can read them.
Folder scope is the one that matters. A credential added to a folder store is visible only to jobs inside that folder. That is how you stop team A's pipeline reading team B's production deploy key.
Give each team Credentials/View and Credentials/Update on their own folder only.
How it is stored
Credentials live encrypted in $JENKINS_HOME/credentials.xml, encrypted with the key material in $JENKINS_HOME/secrets/. Two consequences:
secrets/ plus credentials.xml is everything. Anyone with both has all your secrets. This is why controller filesystem access is equivalent to total compromise.
Your backup contains your secrets. Encrypt it, restrict it, and treat it like the secret store it is.
AWS Secrets Manager / Azure Key Vault / Google Secret Manager plugins — the credential store becomes a view onto them.
Kubernetes Credentials Provider — Kubernetes Secrets appear as Jenkins credentials.
The advantage is not just storage. It is rotation and audit: you can rotate centrally, and you can see who read what and when. Jenkins' own store gives you neither.
Takeaway: always set a readable credential ID, scope credentials to folders rather than globally, and remember that credentials.xml plus secrets/ is your entire secret store in two files.
Using credentials in a pipeline, without leaking them
Requires — Plugin: SSH Build Agents · On the agent: docker · On the agent: kubectl + cluster access
There are two mechanisms, and a set of ways to leak a secret with either.
The secret exists only inside the block. That scoping is the point.
The environment shorthand
Authentication — who can log in
Manage Jenkins → Security has two halves. This is the first: the Security Realm, which decides who is allowed in.
The options
Jenkins' own user database — accounts stored in JENKINS_HOME. Fine for a small team or a personal instance.
Uncheck Allow users to sign up unless you truly want open registration. This is on by default in some setups and is how a public Jenkins acquires uninvited administrators.
LDAP / Active Directory — the standard for a company. Configure the server, bind DN, and the user/group search bases. Use Test LDAP settings before saving; a wrong bind DN locks everyone out including you.
SAML / OIDC — SSO through Okta, Entra, Google, Keycloak. Preferred where available: central offboarding, MFA, and no passwords in Jenkins at all.
GitHub / Google OAuth — practical for open-source or small teams. Restrict by organisation membership, or anyone with a GitHub account can log in.
Unix user/group database — delegates to PAM on the controller.
API tokens
Users authenticate to the API with a token, not their password:
Click your name (top right) → Configure → API Token → Add new Token → name it → Generate. Copy it now; it is not shown again.
The second half of Manage Jenkins → Security: the Authorization strategy.
The strategies
Anyone can do anything — no authorisation. Only ever acceptable on a disconnected laptop.
Legacy mode — admin role for administrators, read for everyone else.
Logged-in users can do anything — the wizard's default. Every authenticated user is effectively an administrator, including script console access. Fine for one person; wrong the moment there are two.
Matrix-based security — a grid of permissions by user or group. Explicit and auditable.
Project-based Matrix Authorization — the matrix, plus per-job overrides.
Role-based Authorization Strategy (plugin) — named roles with pattern-matched scope. The right answer for anything with teams.
The permissions worth understanding
Permission
Grants
Overall/Administer
Everything, including the script console. This is root on the controller.
Overall/Read
See Jenkins exists. Needed for almost anything else.
Pipeline code runs in a sandbox that blocks dangerous calls. Understanding it saves confusion and prevents you from disabling the only thing standing between a developer and your controller.
What it does
Any Jenkinsfile written by a user runs sandboxed. Calls outside an allowlist are rejected:
org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException:
Scripts not permitted to use staticMethod java.lang.System getenv
The block is on method calls, not syntax. Ordinary pipeline work is unaffected.
Approving something
Manage Jenkins → In-process Script Approval
Pending signatures appear here with Approve / Deny. Approving adds that method to the allowlist for everyone, permanently.
Read what you are approving. Some signatures are harmless:
method java.lang.String toLowerCase
Others hand over the machine:
staticMethod jenkins.model.Jenkins getInstance
method java.lang.Runtime exec java.lang.String
new java.io.File java.lang.String
Approving Runtime.exec means any pipeline, from any user, can run any command as the Jenkins user. That is the entire security model gone, granted from a page that looks like a to-do list.
Working within the sandbox instead
Nearly every rejection has a supported alternative:
Rejected
Hardening a Jenkins that faces real users
A checklist, roughly in order of how much it reduces risk.
The high-value items
1. Zero executors on the controller. Covered in Chapter 6. Builds on the controller can read every secret.
2. Keep Jenkins and plugins patched. Plugin CVEs are the main attack surface and are exploited quickly after disclosure. Subscribe to the Jenkins security advisories mailing list. The dashboard banner also flags vulnerable plugins.
3. Do not expose it to the internet. Put it behind a VPN or an identity-aware proxy. If it must be public, at minimum: SSO with MFA, no anonymous read, and a WAF in front.
4. Restrict Overall/Administer to a named, audited list. Review it quarterly.
5. Scope credentials to folders. Chapter opener. A flat global store means every job can use every secret.
6. Turn off open sign-up and remove accounts on offboarding — automatic with SSO, manual otherwise.
The configuration items
7. CSRF protection — on by default; verify at Manage Jenkins → Security.
8. Agent protocols — disable the legacy TCP port if all agents use WebSocket: Security → Agents → TCP port for inbound agents: Disable.
9. Markup formatter — leave at Plain text. Setting it to raw HTML lets anyone who can edit a description inject script into pages other users load.
10. CLI over remoting — disabled by default on modern versions. Confirm it stays that way; it has a poor security history.
11. Build authorisation. Install Authorize Project so builds run as a specific user rather than as SYSTEM. Without it, a build has more authority than the person who triggered it. Manage Jenkins → Security → Access Control for Builds.
12. Set the Jenkins URL — beyond usability, a wrong URL can send agents and callbacks somewhere unintended.
The operational items
13. Back up JENKINS_HOME and test a restore. Chapter 10.
14. Log and monitor. The Audit Trail plugin records who did what. Ship it somewhere you actually read.
Auditing and knowing what happened
Requires — Plugin: Configuration as Code · Plugin: Audit Trail · Plugin: Job Configuration History · On the machine you run this from: jq
When something goes wrong you need to know who changed what, and when.
Audit Trail plugin
Install Audit Trail. Manage Jenkins → System → Audit Trail:
Log location — a file, syslog, or the Jenkins log.
URL patterns to log — the default catches configuration changes and build triggers.
Ship the file to your log system. An audit log nobody reads is a compliance artefact, not a control.
Job Configuration History
The Job Configuration History plugin keeps a versioned diff of every job and global configuration change.
Job → Job Config History, or Manage Jenkins → Job Config History for global changes. You get a side-by-side diff and a restore button.
This is the single most useful plugin when someone says "it worked yesterday" about a Freestyle job. It also answers "who changed the global SMTP settings" — which is otherwise unknowable.
Who ran this build
Every build records its cause:
script {
def causes = currentBuild.getBuildCauses()
echo "Triggered by: ${causes*.shortDescription.join(', ')}"
def user = currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause')
if (user) {
echo "Started by ${user[0].userId}"
}
}
Artifacts, Tests and Quality Gates
Archiving artifacts, and what not to archive
Requires — Plugin: Artifact Manager on S3 · On the agent: aws CLI + credentials
archiveArtifacts attaches files to a build so you can download them later.
fingerprint: true — records an MD5 of each file so Jenkins can track it across jobs. See the fingerprinting note below.
onlyIfSuccessful: true — do not keep artifacts from failed builds. Usually right; occasionally you want failure artifacts, in which case archive them from post { failure } instead.
allowEmptyArchive: false — fail if the pattern matched nothing. Keep this false so a broken build path fails loudly rather than silently producing nothing.
Where they appear
On the build page, under Build Artifacts. And at a predictable URL:
Anything you have already pushed elsewhere. If it went to S3 or Artifactory, archiving it again just doubles the storage.
Do archive:
The built artefact itself, when small.
Test reports and coverage HTML.
Logs that help diagnose a failure.
Retention
options {
buildDiscarder(logRotator(
numToKeepStr: '50', // keep 50 builds' logs and metadata
artifactNumToKeepStr: '5' // keep artifacts for only the last 5
))
}
Splitting these two is the trick: build history is cheap, artifacts are not. Keeping fifty builds of history and five sets of artifacts gives you a useful trend graph without the disk cost.
Offloading to S3
On a busy controller, install Artifact Manager on S3. Once configured, archiveArtifacts and stash transparently write to S3 instead of controller disk. Pipelines do not change; the disk problem goes away.
Alternatively, do it explicitly and skip Jenkins storage entirely:
sh '''
set -euo pipefail
aws s3 cp dist/app.tar.gz \
"s3://artifacts/${JOB_NAME}/${BUILD_NUMBER}/app.tar.gz"
'''
Fingerprinting
With fingerprint: true, Jenkins records a hash for each file. When another job archives or uses a file with the same hash, Jenkins links them.
The payoff is on the artifact's page: "this file was produced by build #142 of api-build and used by build #87 of deploy-prod". Answering "which build is running in production?" becomes a click rather than an investigation.
It costs almost nothing. Turn it on.
Takeaway: archive the artefact and reports, never dependency trees, split numToKeepStr from artifactNumToKeepStr, and turn on fingerprinting to trace a binary across jobs.
Test results — publishing and reading them
Requires — Plugin: JUnit · Plugin: Coverage
A build that says "failed" is much less useful than one that says "3 tests failed, here they are".
JUnit XML
Almost every test framework can emit JUnit XML, which is the format Jenkins understands.
post { always } is essential. If tests fail, make test exits non-zero and the steps block stops — but post still runs, so you still get the report. Putting junit in steps after the test command means you never see results when tests fail, which is exactly when you need them.
allowEmptyResults: false makes a missing report a failure. Without it, a test command that crashed before writing any XML produces a green build with zero tests — the worst possible outcome.
Producing the XML
# pytest
pytest --junitxml=reports/junit.xml
# jest
jest --reporters=default --reporters=jest-junit
# JEST_JUNIT_OUTPUT_DIR=reports
# go
go test ./... -v 2>&1 | go-junit-report > reports/junit.xml
# maven / gradle — automatic
mvn test # target/surefire-reports/*.xml
gradle test # build/test-results/test/*.xml
# dotnet
dotnet test --logger "junit;LogFilePath=reports/junit.xml"
# rspec
bundle exec rspec --format RspecJunitFormatter --out reports/junit.xml
Static analysis and warnings trends
Requires — Plugin: Warnings Next Generation · Plugin: OWASP Dependency-Check · On the agent: trivy · On the agent: gitleaks · +1 more, named inline
The Warnings Next Generation plugin parses compiler and linter output into trends, annotated source, and quality gates. It replaced a dozen older plugins and understands well over a hundred formats.
Requires the HTML Publisher plugin. A link appears in the job and build sidebars.
keepAll: true — keep the report for every build, not just the latest. Costs disk; worth it for coverage where you want to compare.
allowMissing: false — fail if the directory is not there, rather than publishing an empty link.
The CSP problem
Out of the box, published HTML often renders unstyled — no CSS, no JavaScript, no images. This is Jenkins' Content-Security-Policy on served files, and it is deliberate: a build can write arbitrary HTML, and serving it with scripts enabled from the Jenkins origin would be stored XSS against your own users.
The usual advice is to relax it from the script console:
Understand the trade-off before doing this. Loosening it means any build output can execute script in the Jenkins origin, in the browser of anyone who views the report. On a shared Jenkins that is a genuine cross-user attack path.
Safer options:
Keep the sandbox directive ( first) — it blocks the dangerous parts while allowing styling.
Quality gates that people do not route around
Requires — Plugin: Workspace Cleanup · Plugin: JUnit · Plugin: Coverage · On the agent: trivy · +1 more, named inline
A quality gate is a rule that fails a build. The design problem is not technical — it is making a rule that survives contact with a deadline.
Why gates get disabled
Every failed gate is a person blocked. If the gate is:
Slow — it gets skipped "just this once".
Flaky — it gets retried until green, which trains people to ignore it.
Unachievable — inherited debt means it can never pass, so it gets commented out.
Unexplained — a red X with no actionable message gets escalated, then waived.
A gate that is bypassed provides negative value: it costs time and provides no assurance.
Applies to coverage, lint, complexity, and security findings alike. You cannot fix the past in this pull request; you can be responsible for what you touched.
2. Fail fast and cheap.
Build speed — where the time actually goes
Requires — Plugin: Job Cacher · On the agent: docker · On the machine you run this from: jq
Slow pipelines get worked around. Measuring first is the whole trick.
Find out where time goes
Stage View gives per-stage timings across builds. The stage that grew is usually obvious.
For per-step detail, the Pipeline Steps page shows each step's duration. Or use the API:
Takeaway: always wrap input in a timeout, and keep it off an agent so a pending approval does not hold an executor for days.
script blocks, and staying out of them
Declarative is deliberately limited. When you need real logic — loops, try/catch, variables — you open a script block and write Scripted Groovy inside it.
The escape hatch
stage('Build') {
steps {
script {
def targets = ['api', 'worker', 'cron']
for (t in targets) {
sh "make build TARGET=${t}"
}
}
}
}
When it is legitimate
Loops over a list computed at runtime.
try / catch / finally around something you must recover from.
Building a map or list to pass to a step.
readJSON / readYaml then branching on the contents.
When it is a smell
An 80-line script block is a pipeline that should be a shared library function (Chapter 9) or, more often, a shell script in your repository:
steps {
sh './ci/build.sh'
}
Logic in a shell script can be run locally, tested, reviewed and debugged without Jenkins. Logic in a script block only runs inside Jenkins. Move logic into the repository wherever you can.
The serialisation trap
Pipeline code is checkpointed so a build survives a controller restart. Every local variable must be serialisable, and the rules surprise people.
This fails:
Why a shared library, and when not to
Requires — Plugin: Docker Pipeline
When twenty repositories have nearly the same Jenkinsfile, a change means twenty pull requests. A shared library puts the common part in one place.
The problem it solves
// Repeated in every one of your services
stage('Docker') {
steps {
script {
docker.withRegistry('https://registry.example.com', 'registry-creds') {
def img = docker.build("api:${env.BUILD_NUMBER}")
img.push()
img.push('latest')
}
}
}
}
More than about five repositories sharing a pattern.
Standards you want enforced centrally — every build scans for secrets, every deploy records who approved it.
Complex logic that deserves review and tests.
When it is not
Fewer than five repos. Copy-paste is genuinely fine at that scale, and much easier to debug.
Logic that could be a shell script. A ci/build.sh in each repo can be run locally, tested, and debugged without Jenkins. A library function can only run inside Jenkins.
Logic that could be a container image. If the goal is "everyone uses the same tools", a shared base image achieves it without any Groovy.
The failure mode of shared libraries is a 3,000-line Groovy codebase that only two people understand, that nobody can test, and that every build in the company depends on. That is a worse position than duplicated Jenkinsfiles.
Requires — Plugin: Kubernetes · Plugin: Pipeline: Shared Groovy Libraries · On the agent: docker
vars/ is for steps. src/ is for real code with structure.
A class
// src/com/example/ci/Version.groovy
package com.example.ci
class Version implements Serializable {
private static final long serialVersionUID = 1L
int major, minor, patch
Version(String s) {
def parts = s.replaceAll(/^v/, '').tokenize('.')
major = parts[0] as int
minor = parts[1] as int
patch = parts[2] as int
}
Version bumpMinor() {
return new Version("${major}.${minor + 1}.0")
}
String toString() {
return "${major}.${minor}.${patch}"
}
}
// in a Jenkinsfile or vars/ file
import com.example.ci.Version
def v = new Version('v1.4.2').bumpMinor()
echo "next: ${v}" // 1.5.0
implements Serializable is not optional. Pipeline state is checkpointed, and any object held across a step boundary must serialise. Omitting it produces NotSerializableException at some unpredictable later point.
Reaching pipeline steps from a class
Classes have no access to sh, echo and friends. Pass the pipeline in:
Testing a shared library
Requires — Plugin: Pipeline: Shared Groovy Libraries · Test dependency: JenkinsPipelineUnit + Spock · On the machine you run this from: jq
A library every build depends on needs tests. Groovy makes this harder than it should be, but it is achievable.
Level 1: the linter
Cheapest useful check — validate every Jenkinsfile in CI:
No pipeline {}, no steps {}. node() allocates an executor and a workspace; everything else is ordinary Groovy.
What it gives you
Real control flow at the top level:
node {
def services = readJSON(file: 'services.json')
for (svc in services) {
if (svc.enabled) {
stage("Deploy ${svc.name}") {
sh "make deploy SERVICE=${svc.name}"
}
}
}
}
Stages generated at runtime:
Inheriting and Administering a Jenkins
Installing Jenkins on Ubuntu, properly
The official Debian/Ubuntu package. This is the setup you want on a VM you control.
Prerequisites
Jenkins is a Java application, and the supported JVM range changes with the release line.
The current LTS (the 2.555.1 line, April 2026) requires Java 21 or Java 25; Java 17 is no
longer sufficient for it. Always check the
Java support policy
for the line you are installing — Jenkins refuses to start on an unsupported JVM.
Note debian-stable in both URLs. That is the LTS line — releases every 12 weeks with backported fixes. The other line, debian, is weekly. Use LTS unless you have a specific reason not to.
Open http://<host>:8080, paste it, and you get the setup wizard.
The setup wizard, decided in advance
Install suggested plugins. Yes, on a first install. It gives you Git, Pipeline, credentials, and the basics. You can prune later.
Create the first admin user. Do it now. Do not skip and keep using the initial admin password — that account has an unchangeable name and a password sitting in a file on disk.
Jenkins URL. Set it correctly, including scheme and port. Email links, webhook callbacks and the agent JNLP URL all derive from it. Getting this wrong causes confusing breakage weeks later.
Where things live
Path
What
/var/lib/jenkins
JENKINS_HOME — all config, jobs, builds, plugins, secrets
/var/lib/jenkins/jobs/<name>/builds
Build history and logs
/var/lib/jenkins/secrets
Master key and initial admin password
journalctl -u jenkins
Application log — the systemd packages log to the journal
/etc/default/jenkins
Port, JVM args, user (Debian)
JENKINS_HOME is the whole product. Back that directory up and you can rebuild Jenkins anywhere. Lose it and you have lost everything. Chapter 10 covers doing that properly.
Changing the port
Port 8080 collides with almost everything. On modern packages:
sudo systemctl edit jenkins
[Service]
Environment="JENKINS_PORT=8081"
sudo systemctl restart jenkins
Takeaway: install from the LTS apt repository, set the Jenkins URL correctly during the wizard, and know that /var/lib/jenkins is the only thing that matters for backup.
Release lines, version pinning and installing elsewhere
Requires — Plugin: Configuration as Code · On the agent: docker · On the machine you run this from: jq · Locally: helm
Jenkins ships on two lines, and picking the wrong one is a slow-burning mistake.
Weekly versus LTS
Line
Cadence
Use when
Weekly
Every week
You need a fix or feature immediately, and you can absorb churn
LTS
Every 12 weeks, with patch releases between
Everything else
LTS is chosen from a recent weekly release and then receives backported fixes for its cycle. That means fewer surprises and a real patch stream.
The apt repository URL differs by one path segment, which is easy to get wrong:
apt-cache policy jenkins
# or, from a running instance
curl -s https://ci.example.com/api/json | jq -r .
# the version is also in the footer of every page and at /manage
Pinning a version
Unattended upgrades that bump Jenkins without you knowing is a bad surprise. Hold it:
sudo apt-mark hold jenkins
apt-mark showhold
# when you are ready
sudo apt-mark unhold jenkins
sudo apt-get install --only-upgrade jenkins
Jenkins behind a reverse proxy with TLS
Jenkins speaks plain HTTP on 8080. In front of it you want nginx or Caddy terminating TLS. Doing this wrong produces a Jenkins that half-works in ways that are hard to diagnose.
Why it goes wrong
Jenkins builds absolute URLs from what it thinks its address is. Behind a proxy it sees http://localhost:8080 while users see https://ci.example.com. Mismatch causes:
The "It appears that your reverse proxy set up is broken" banner.
Broken redirects after login.
Webhooks that fire but link to the wrong place.
WebSocket agents failing to connect.
nginx
server {
listen 443 ssl http2;
server_name ci.example.com;
ssl_certificate /etc/letsencrypt/live/ci.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ci.example.com/privkey.pem;
# Build logs stream; buffering them makes the console look frozen.
proxy_buffering off;
# Large artifact uploads and plugin installs need headroom.
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for inbound agents over WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 90s;
}
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ci.example.com;
return 301 https://$host$request_uri;
}
Jenkins core does very little. Git support, pipelines, credentials, agents, JUnit reporting — all plugins. There are over 1,800 of them.
Where they live
Dashboard → Manage Jenkins → Plugins
Four tabs:
Updates — upgrades for what you have.
Available plugins — the catalogue.
Installed — what you have, with the option to disable or uninstall.
Advanced settings — proxy configuration and uploading a .hpi file by hand (for air-gapped installs).
Installing
Search, tick, and choose Install without restart or Download now and install after restart. Most plugins install live; some need the restart. If in doubt, restart during a quiet window — a plugin half-loaded is a strange thing to debug.
To restart cleanly, letting running builds finish first:
http://<jenkins>/safeRestart
Versus the blunt instrument, which kills running builds:
http://<jenkins>/restart
The plugins you will actually use
Plugin
Why
Pipeline (workflow-aggregator)
The whole pipeline system. Non-negotiable.
Git / GitHub
Checkout and webhook integration.
Credentials Binding
Injects secrets into builds as environment variables.
Blue Ocean
Nicer pipeline visualisation. Now in maintenance — see the note on it in Chapter 2.
Manage Jenkins — a guided tour of every section
Dashboard → Manage Jenkins is where all administration lives. It is a wall of links. Here is what each group is actually for, so you can stop hunting.
System Configuration
System — global settings: Jenkins URL, admin email, global environment variables, SMTP, and the configuration blocks that installed plugins add. This page grows enormous. Use your browser's find.
Tools — JDK, Maven, Gradle, Git, NodeJS installations. Declare them here; reference them by name in pipelines.
Plugins — install, update, remove. Covered in Chapter 1.
Nodes — your agents. Add, configure, take offline. Chapter 6.
Clouds — dynamic agents: Docker, Kubernetes, EC2. Appears once you install a cloud plugin.
Appearance — themes and the system message banner.
Security
Security — the big one: security realm (who can log in) and authorization (what they can do). Also CSRF, agent protocols, and Markup Formatter.
Credentials — the credential store. Chapter 7.
Credential Providers — which credential backends are enabled.
Users — accounts, when using Jenkins' own database.
Status Information
System Information — JVM properties, environment variables, plugin versions, OS. The first thing to attach to a bug report.
System Log — the application log, live, in the browser. You can add custom loggers scoped to a package, which is the single best debugging trick in Jenkins. See the troubleshooting note in Chapter 10.
Load Statistics — graphs of queue length and executor utilisation over time. Answers "do we need more agents?" with data.
About Jenkins — version and licences.
Troubleshooting
Manage Old Data — configuration left behind by removed plugins. Worth clearing after a plugin purge.
Tools and Actions
Reload Configuration from Disk — re-reads JENKINS_HOME without a restart. Use after editing XML by hand.
Freestyle jobs — what they are and why you will still meet them
A Freestyle project is the original Jenkins job: a web form where you tick boxes and paste shell commands. No code, no repository, all configuration in the UI.
Modern advice is "use Pipeline". That is correct. You still need to understand Freestyle because you will inherit hundreds of them.
Creating one
Dashboard → New Item → enter a name → Freestyle project → OK.
Names become URLs. Avoid spaces — api build becomes /job/api%20build/, which every script you write later will have to encode. Use api-build.
The configuration page, section by section
General
Description — supports HTML if the markup formatter allows it.
Discard old builds — set this. Days to keep, and max builds to keep.
This project is parameterised — adds a form when triggering.
Restrict where this project can be run — a label expression. The Freestyle equivalent of agent { label '…' }.
Source Code Management
None / Git / Subversion. For Git: repository URL, credentials, and Branches to build (*/main).
Build Triggers
Build periodically — cron. Runs whether or not anything changed.
Poll SCM — cron, but only builds if the repo changed. Wasteful at scale; prefer webhooks (Chapter 5).
Build after other projects are built — simple chaining.
GitHub hook trigger for GITScm polling — the webhook option.
Build Environment
Delete workspace before build starts — slower but eliminates a whole class of "works on rebuild" mysteries.
Use secret text(s) or file(s) — binds credentials into environment variables. Chapter 7.
Add timestamps to the Console Output — turn this on everywhere. Costs nothing, and a log without timestamps is much harder to reason about.
The Script Console — power, and the reason it is dangerous
Manage Jenkins → Script Console, or /script. A box that runs Groovy inside the Jenkins JVM, as the Jenkins user, with no sandbox.
Why it exists
Some things have no UI. Bulk operations, inspecting internal state, repairing a broken configuration. The script console is the escape hatch.
who-am-i prints your authorities — the quickest way to check what permissions a token actually carries.
The commands worth knowing
Operating Jenkins in Production
Configuration as Code — the whole controller in YAML
Requires — Plugin: Configuration as Code · Plugin: Role-based Authorization Strategy · Plugin: Pipeline: Multibranch (+ a branch source such as GitHub) · On the agent: docker
JCasC describes your controller's configuration in a YAML file. Instead of clicking through Manage Jenkins, you commit a file.
Why it matters
Reproducible. A new controller comes up configured. Disaster recovery becomes "apply the YAML".
Reviewable. Configuration changes go through pull requests, which Jenkins otherwise has no mechanism for (Chapter 7).
Diffable. "Who changed the SMTP server" is answerable.
Setup
Install Configuration as Code. Point Jenkins at the file with an environment variable or a system property:
# /etc/default/jenkins, or the systemd unit
CASC_JENKINS_CONFIG=/var/lib/jenkins/casc/jenkins.yaml
Or a directory, or a URL. On restart Jenkins applies it.
Manage Jenkins → Configuration as Code gives you Reload existing configuration, View configuration, and — most usefully — Download current configuration, which exports your existing setup as YAML. Start there rather than writing it from scratch.
A realistic file
jenkins:
systemMessage: "PRODUCTION CI — configuration is managed by JCasC. Changes via PR only."
numExecutors: 0 # no builds on the controller
mode: EXCLUSIVE # only run jobs that ask for this node by label
quietPeriod: 5
scmCheckoutRetryCount: 3
securityRealm:
local:
allowsSignup: false
users:
- id: "admin"
password: "${ADMIN_PASSWORD}" # from the environment
authorizationStrategy:
roleBased:
roles:
global:
- name: "admin"
permissions: ["Overall/Administer"]
entries:
- group: "platform-team"
- name: "developer"
permissions: ["Overall/Read", "Job/Build", "Job/Cancel"]
entries:
- group: "developers"
remotingSecurity:
enabled: true
nodes:
- permanent:
name: "build-1"
remoteFS: "/home/jenkins/agent"
numExecutors: 4
labelString: "linux docker build"
launcher:
ssh:
host: "build-1.internal"
credentialsId: "agent-ssh-key"
sshHostKeyVerificationStrategy:
knownHostsFileKeyVerificationStrategy
credentials:
system:
domainCredentials:
- credentials:
- usernamePassword:
scope: GLOBAL
id: "registry-creds"
username: "ci"
password: "${REGISTRY_PASSWORD}"
description: "Container registry"
unclassified:
location:
url: "https://ci.example.com/"
adminAddress: "ci-admin@example.com"
globalLibraries:
libraries:
- name: "ci-lib"
defaultVersion: "v3"
implicit: false
retriever:
modernSCM:
scm:
git:
remote: "git@github.com:acme/ci-lib.git"
credentialsId: "github-deploy-key"
tool:
git:
installations:
- name: "Default"
home: "git"
jdk:
installations:
- name: "jdk21"
home: "/usr/lib/jvm/java-21-openjdk-amd64"
Secrets in JCasC
Never commit secrets.${VAR} reads from the environment:
Do not try to write the whole file at once. Export what you have, commit that as a baseline, then move sections under management one at a time — validating with View configuration after each. A JCasC file that is 80% correct and applied on restart can undo settings you did not realise it owned.
Takeaway: export your current config first, keep secrets in environment variables, and adopt JCasC section by section rather than all at once.
Backup and restore, tested
Requires — On the agent: aws CLI + credentials · On the controller: gpg
JENKINS_HOME is the entire product. Everything else is replaceable.
What actually needs backing up
$JENKINS_HOME/
├── config.xml ← global configuration
├── credentials.xml ← encrypted secrets
├── secrets/ ← the keys that decrypt them ** critical **
├── jobs/ ← job configs and build history
│ └── <job>/
│ ├── config.xml
│ └── builds/ ← the bulky part
├── users/
├── nodes/
├── plugins/ ← .jpi files; re-installable
└── workspace/ ← DO NOT back up; regenerable
The controller key is the exception, and it matters. Jenkins' own guidance is blunt: treat $JENKINS_HOME/secrets/master.key"like you treat your SSH private key and NEVER include it in a regular backup". It decrypts everything else in secrets/, so a backup containing both is a single object that unlocks every credential you own.
Two backup sets, stored apart:
Set A — the regular backup Set B — the controller key
JENKINS_HOME minus workspaces $JENKINS_HOME/secrets/master.key
encrypted, offsite, nightly separate, highly restricted, rarely touched
| |
+----------- restore needs BOTH ---------+
A full restore applies A, then puts B back. Rehearse it that way — a restore drill that quietly uses the key already sitting in the same archive proves nothing.
Upgrading without breaking everything
Requires — On the agent: docker
Jenkins ships LTS every 12 weeks; plugins update constantly. Upgrades are routine and occasionally destructive.
Before
Back up, and confirm the backup is good. Not the scheduled one — a fresh one, now.
Read the changelog.jenkins.io/changelog-stable for core; each plugin's page for plugins. Look for "breaking".
Check Java compatibility. Newer Jenkins raises the minimum Java version periodically, and it is the most common upgrade blocker.
Prepare for shutdown — Manage Jenkins → Prepare for Shutdown stops new builds and lets running ones finish.
The order
Plugins before and after core. The Jenkins LTS upgrade guide is explicit: "you must ensure that all plugins have been updated both before and after upgrading. If plugins are not updated both before and after the upgrade, compatibility issues may arise."
The runbook:
1. Read the upgrade guide for EVERY LTS line you are skipping, not just the target
2. Fresh backup, and confirm it restores
3. Update plugins to their current versions ← before
4. Manage Jenkins → Prepare for Shutdown
5. Upgrade core
6. Start, and check the log for plugin load failures
7. Update plugins again, now aligned to the new core ← after
8. Run a smoke pipeline on each agent type
Steps 3 and 7 are both required. Updating only afterwards leaves you starting the new core against plugins built for the old one, which is the state that produces a controller that will not come up.
Monitoring and knowing before users tell you
Requires — Plugin: Prometheus metrics · On the machine you run this from: jq
The built-in views
Manage Jenkins → Load Statistics — queue length and executor use over time. Answers "do we need more agents" with data rather than opinion.
Manage Jenkins → Nodes — disk, swap, clock skew, response time per agent.
Manage Jenkins → System Information — JVM memory, uptime, versions.
Prometheus
Install Prometheus metrics. It exposes /prometheus/ for scraping.
Requires — Plugin: Workspace Cleanup · Plugin: Job Configuration History
The problems that actually happen, and what they mean.
Nothing is building; the queue is growing
Hover the ⓘ next to the queued item. Jenkins states the reason:
"there are no nodes with the label 'docker'" — no agent has that label. Check Manage Jenkins → Nodes and the agent's label string. Usually an agent went offline or a label was renamed.
"Waiting for next available executor" — genuine capacity. Add agents or executors.
"Build #N is already in progress" — disableConcurrentBuilds() doing its job.
"…is offline" — the named agent is down.
An agent will not connect
Node page → Log shows the launch transcript. See Chapter 6's table. In short: keys, host key verification, Java version, and the remote root's permissions cover almost all of it.
A build hangs forever
options { timeout(time: 30, unit: 'MINUTES') }
Add this first so it becomes a failure rather than a mystery. Then find the cause:
A command waiting on stdin. Non-interactive flags: apt-get -y, git -c core.askpass=true, ssh -o BatchMode=yes.
A process that does not exit. sh 'nohup ./server &' keeps the step alive; use sh 'nohup ./server >/dev/null 2>&1 &' with a proper disown, or JENKINS_NODE_COOKIE=dontKillMe if it should survive.
An input with no timeout (Chapter 4).
Thread dump for a truly stuck controller: https://ci.example.com/threadDump.
Works locally, fails in Jenkins
In order of likelihood:
Environment. Jenkins shells are non-interactive and non-login, so ~/.bashrc is not read. , , are not initialised. Add and compare.
Deployment patterns and promoting a build
Requires — On the agent: docker · On the machine you run this from: jq
CI produces an artefact. CD gets it into an environment. Jenkins does both, and the main design decision is not to rebuild.
Requires — Plugin: Kubernetes · Plugin: Configuration as Code
Jenkins scales well up to a point, then does not. Knowing the ceiling helps you plan.
Rough capacity of one controller
A well-tuned controller handles roughly:
Hundreds to a couple of thousand jobs.
50–100 concurrent builds, given enough agents.
A few hundred agents, though connection overhead grows.
Symptoms of hitting the ceiling: slow UI, long queue-scheduling latency, GC pauses, restarts taking many minutes.
Vertical first — it is usually enough
More heap — see the monitoring note.
Fewer retained builds — thousands of build directories per job make everything slow. Discarders.
Faster disk — JENKINS_HOME on SSD/NVMe. Jenkins is heavy on small file operations, and this often matters more than CPU.
Artifacts off the controller — S3.
Zero executors on the controller — again.
Most "we need to scale Jenkins" situations are solved by the first two.
Horizontal — multiple controllers
There is no clustering. A Jenkins controller is a single process with a single JENKINS_HOME. Scaling out means more controllers, split by team or product.
Trade-offs:
Isolation — one team's outage does not stop everyone. Genuinely valuable.
Blast radius — a compromised controller holds fewer credentials.
Cost — more instances to patch, back up and upgrade. This is the real price.
Fragmentation — no single view.
JCasC makes this bearable: a common configuration repository, one controller per team, each reproducible from YAML.
Ephemeral agents
Before splitting controllers, make agents elastic. Kubernetes (Chapter 6) or EC2/cloud plugins create agents on demand and destroy them after.
A production readiness checklist
Requires — Plugin: Configuration as Code · Plugin: Audit Trail
Everything in this book, condensed into things you can verify.
Security
[ ] Controller executors = 0
[ ] Not reachable from the internet without SSO/VPN
[ ] Overall/Administer granted to a short, named, reviewed list
[ ] Open sign-up disabled
[ ] Credentials scoped to folders, not all global
[ ] Agent SSH keys use System scope so builds cannot read them
[ ] Host key verification is not "Non verifying"
[ ] Fork PRs do not run on agents holding credentials
[ ] Script approvals reviewed — no Runtime.exec, no Jenkins.getInstance
[ ] Write access to global shared libraries restricted and review-gated
[ ] Plugins updated; no outstanding security advisories
[ ] Audit Trail installed and shipped somewhere it is read
Reliability
[ ] JENKINS_HOME backed up nightly, encrypted, offsite
[ ] Backup restored end-to-end within the last quarter
[ ] Every job has a buildDiscarder
[ ] Artifacts split from build history in retention settings
[ ] Controller disk monitored with alerting
[ ] Agent offline alerting
[ ] Queue-depth alerting
[ ] JVM heap sized and monitored
[ ] Every pipeline has a timeout
Configuration
Test Result — appears once a test publisher runs. Chapter 8.
Previous/Next Build — navigate the history.
Console output tricks
The raw log, without the HTML wrapper — much faster on a huge build and easy to grep:
consoleFull gives the whole thing in one HTML page rather than the progressive view — useful when you want your browser's find across the entire log.
Build numbers and permalinks
/job/api-build/142/ a specific run
/job/api-build/lastBuild/ most recent, whatever the result
/job/api-build/lastSuccessfulBuild/
/job/api-build/lastFailedBuild/
/job/api-build/lastSuccessfulBuild/artifact/dist/app.tar.gz
That last pattern — a stable URL to the latest good artifact — is how other systems pull builds out of Jenkins without an API client.
Takeaway: Stage View tells you which stage broke, Pipeline Steps tells you which step, and consoleText gives you something greppable. Restart from Stage and Replay will save you hours.
/api/json
# every job and its colour, in one call
curl -s https://ci.example.com/api/json?tree=jobs\[name,color\] | jq
# the result of the last build
curl -s https://ci.example.com/job/api-build/lastBuild/api/json?tree=result | jq -r .result
# which stage failed
curl -s https://ci.example.com/job/api-build/lastBuild/wfapi/describe | jq '.stages[] | {name, status}'
The tree= parameter limits the response to the fields you name and turns a megabyte of JSON into a line. wfapi/describe is the pipeline-specific endpoint that returns per-stage status — the thing to script against for a status dashboard.
Personal views
Dashboard → My Views → +. A personal dashboard of just your team's jobs, without changing anything for anyone else. The first thing to set up on a shared Jenkins with 200 jobs.
Takeaway: learn the URL shapes, especially the repeated /job/ for folders, and remember that ?tree= turns the JSON API into something you can use from a shell.
Build Monitor
Favourites
With the Favorite plugin, a star appears next to each job. Starred jobs surface in a dedicated view. Lighter weight than maintaining a view definition, and it survives job renames.
Sensible defaults for a new joiner
Worth writing into your team's onboarding:
1. Set your full name and email in /user/<you>/configure
2. Create an API token named after the machine you will use it from
3. Create a My View filtered to your team's job prefix
4. Turn on "Recurse in subfolders" and add the Last Duration column
5. Set that view as your default so / lands somewhere useful
Dark mode and themes
Manage Jenkins → Appearance (administrator only) sets the instance theme. Recent Jenkins ships a built-in dark theme; older instances need the Dark Theme plugin.
There is no per-user theme selection in core, which surprises people. If your team is split on it, the Theme Manager plugin adds a per-user preference.
Takeaway: set your email, create a filtered personal view with "Recurse in subfolders" on, and make it your default. Five minutes, and a large Jenkins becomes navigable.
Reading a heavily parallel pipeline
Pipeline Graph View
Per-step logs on a long build
Pipeline Steps, or Graph View
Anything administrative
Classic Jenkins
Takeaway: Stage View plus Pipeline Graph View covers everything Blue Ocean did that mattered. Blue Ocean is deprecated as of July 2026 and receives no further security fixes — do not start anything new on it.
Three platforms × two browsers = six cells, minus one exclusion = five. Each is a real parallel branch with its own agent and its own column in the UI.
Matrix is the right tool the moment you are copy-pasting near-identical parallel stages.
Nested sequential stages
Inside a parallel branch you often want several steps in order:
A pipeline holds an executor for its whole duration when using a top-level agent. Every parallel branch with its own agent takes another. A matrix of 12 with agent per cell wants 12 executors plus the one holding the pipeline.
If your matrix seems to run sequentially, count your executors. Manage Jenkins → Nodes shows how many you have; the queue tooltip says what it is waiting for.
Use agent none at the top when every stage declares its own, so the pipeline itself does not hold an executor while waiting.
Takeaway: parallel for a handful of different things, matrix for combinations of the same thing. Then count executors — parallelism you lack capacity for is just a queue.
By default Jenkins allocates the agent, then evaluates when. If the condition is false you spun up a container and a workspace for nothing.
git diff --quiet returns 1 when there are differences, so == 1 means "something under infra/ changed relative to main".
Skipped stages in the UI
A skipped stage shows as a pale, striped cell in Stage View rather than disappearing. That is deliberate — it tells you the stage exists and chose not to run.
Takeaway:when with beforeAgent true should be your default. Treat changeset as unreliable on first builds and compute path filters with git diff when correctness matters.
steps
post
which
Notification that does not get muted
The failure mode of CI notification is volume. A channel that pings on every build is ignored within a week, and then a real failure goes unnoticed.
culprits() mails whoever committed since the last successful build — usually exactly the right list. Requires the Email Extension plugin; SMTP is configured at Manage Jenkins → System → Extended E-mail Notification.
Cleanup
post {
cleanup {
cleanWs()
}
}
cleanup runs last and always. cleanWs() (Workspace Cleanup plugin) deletes the workspace, which is what keeps agents from filling their disks. Archive before you clean.
Marking a build unstable deliberately
steps {
script {
def status = sh(script: 'make lint', returnStatus: true)
if (status != 0) {
unstable('Lint found problems')
}
}
}
unstable() marks the build yellow without failing it. Right for things you want visible but not blocking. Use sparingly — a permanently yellow pipeline is as ignorable as a noisy channel.
Takeaway: publish reports in post { always }, notify on fixed and regression, clean the workspace in cleanup.
The first-run gotcha
A parameterised pipeline must run once before the parameters exist. Jenkins learns them by executing the Jenkinsfile. So the first build after adding parameters runs with defaults and shows no form; the second shows the form.
This confuses everyone once. If the build button says "Build Now" instead of "Build with Parameters", run it once.
Choice parameters that come from somewhere
Static lists go stale. Active Choices plugin lets a parameter be computed:
Using an API token rather than a password: click your name → Configure → API Token → Add new Token.
A word on password parameters
password() masks the value in the UI form. It does not reliably mask it in the console if you echo it, and it is stored in the build record. For real secrets use the credential store (Chapter 7). Password parameters are for break-glass values that are already short-lived.
Takeaway: parameters make a job reusable. Remember the first run has no form, use params.NAME, and never put a real secret in a password parameter.
Create the job with Pipeline script (inline) and get a skeleton green.
Add one stage at a time, running after each.
When a stage misbehaves, Replay and poke at it — sh 'env | sort', sh 'pwd && ls -la'.
Once it works, move the script to Jenkinsfile and switch Definition to Pipeline script from SCM.
From then on, iterate with Replay and commit the result.
The mistake to avoid is starting from a 200-line Jenkinsfile copied from somewhere and debugging all of it at once.
Takeaway: Replay for iterating on pipeline logic, Restart from Stage for re-running an expensive pipeline's tail. Always copy a working Replay back into the repository.
when
Global Variable Reference — documents env, params, currentBuild, scm and anything your shared libraries expose. This is where you look up what currentBuild actually offers.
Steps Reference — every step available on this instance, from the plugins you actually have installed. Better than the website, which documents plugins you may not have.
IntelliJ IDEA GDSL — a file that gives you Jenkinsfile autocomplete in JetBrains IDEs.
currentBuild, worth knowing
currentBuild.result // 'SUCCESS' | 'FAILURE' | 'UNSTABLE' | null while running
currentBuild.currentResult // same, but never null
currentBuild.displayName // '#142' — writable
currentBuild.description // shown in the build list — writable
currentBuild.duration
currentBuild.startTimeInMillis
currentBuild.previousBuild // chain backwards
currentBuild.changeSets // commits in this build
Returns Jenkinsfile successfully validated. or the parse error with a line number. Worth wiring into a pre-commit hook — it catches syntax mistakes before you burn a build.
Takeaway: use the Snippet Generator instead of guessing step syntax, look up currentBuild in Global Variable Reference, and lint Jenkinsfiles over HTTP before committing.
Bitbucket
Install Bitbucket plugin. Endpoint:
https://ci.example.com/bitbucket-hook/
The generic webhook, for everything else
The Generic Webhook Trigger plugin accepts any POST and lets you pull values out of the payload with JSONPath:
The token here is what authorises the call — treat it as a secret. This is how you wire Jenkins to systems that have no dedicated plugin.
Debugging a webhook that does not fire
Work down this list; it is almost always one of these.
Did the hook leave the provider? GitHub → Settings → Webhooks → click the hook → Recent Deliveries. You get the request, the response, and the status code. This single page answers most questions.
Response 302 to a login page → Jenkins requires authentication for anonymous users and the webhook cannot get in. Grant anonymous Job/Read or use a token-based trigger.
Response 403 → CSRF. The proper endpoints (/github-webhook/) are exempt; a custom one may not be.
200 but nothing builds → Jenkins accepted it but matched no job. Check that the repository URL in the job exactly matches the payload, including git@ vs https://. This is the most common cause.
Jenkins is not reachable from the internet. GitHub cannot call your laptop. For local development use a tunnel:
ngrok http 8080
# then set the payload URL to the ngrok https URL + /github-webhook/
Check the receiving end.Manage Jenkins → System Log, add a logger for com.cloudbees.jenkins.GitHubWebHook at FINE. You will see each delivery and the routing decision.
Keep polling as a fallback
triggers {
pollSCM('H/30 * * * *')
}
A slow poll alongside webhooks means a missed delivery costs you thirty minutes rather than a day. Costs little, saves the occasional confusing morning.
Takeaway: the provider's "Recent Deliveries" page tells you what happened. A 200 with no build almost always means the repository URL in the job does not match the payload.
Branch names with / become %2F in URLs. This trips up scripts constantly.
Scan Repository Now in the sidebar forces a re-scan. Scan Repository Log tells you what it found and, more usefully, why it skipped something.
Branch-aware pipelines
Because one Jenkinsfile serves every branch, when does the differentiating:
stage('Deploy to staging') {
when { branch 'develop' }
steps { sh 'make deploy-staging' }
}
stage('Deploy to production') {
when {
beforeAgent true
allOf {
branch 'main'
not { changeRequest() }
}
}
steps { sh 'make deploy-prod' }
}
stage('PR checks') {
when { changeRequest() }
steps { sh 'make lint && make test' }
}
Pull-request environment variables
Inside a PR job you get:
CHANGE_ID 431
CHANGE_TARGET main
CHANGE_BRANCH feature/new-parser
CHANGE_AUTHOR alice
CHANGE_TITLE Add the new parser
CHANGE_URL https://github.com/acme/api/pull/431
BRANCH_NAME PR-431
Merge versus head
For PRs, "Discover pull requests from origin" offers:
Merging the pull request with the current target branch revision — builds the merge result. Tests what will exist after merge. Usually correct.
The current pull request revision — builds the PR branch as-is. Faster, and stable (the commit does not change when main moves).
Merge strategy is the honest one, but it means a PR can go red because main changed. Many teams build both.
The fork trust problem
"Discover pull requests from forks" lets an outside contributor's code run on your agents. That code can read anything the build can — including credentials.
The Trust setting controls whose Jenkinsfile is used:
Nobody — safest. Uses the Jenkinsfile from the target branch, so a fork cannot change the pipeline.
Contributors / Everyone — progressively more dangerous.
Even with Nobody, the fork's code still executes (your pipeline runs their tests). For a public repository, run fork PRs on isolated, credential-free agents. This is not paranoia — it is one of the most commonly exploited CI weaknesses.
Takeaway: Multibranch is the right default. Set Orphaned Item Strategy, remember %2F in URLs, and think carefully before building fork PRs on agents that hold credentials.
A shallow clone of depth 1 with no tags is why git describe returns "fatal: No names found" in CI but works on your laptop. It is one of the most common CI-only failures.
Note the deploy key must have write access, which most do not by default. And be careful: a pipeline that pushes a tag can trigger itself. Either filter the tag out of the branch discovery, or guard with when { not { buildingTag() } }.
gh reads GH_TOKEN from the environment, so the token never appears on the command line — which matters, because command lines are visible in process listings on a shared agent.
Takeaway: tag builds need noTags: false and enough depth for git describe. Guard any pipeline that pushes tags against triggering itself.
cron('H H(2-4) * * *') // once a day, some minute in the 02:00–04:59 window
cron('H/15 * * * *') // every 15 minutes, offset per job
cron('H H * * 1-5') // once a day, weekdays only
Named aliases
cron('@hourly') // == H * * * *
cron('@daily') // == H H * * *
cron('@midnight') // == H H(0-2) * * *
cron('@weekly')
cron('@monthly')
These already use H, so they are safe.
Poll SCM versus cron
triggers {
pollSCM('H/5 * * * *') // check every 5 min; build only if changed
cron('H 2 * * *') // build at 2am regardless
}
pollSCM still contacts the repository on every tick. With a hundred jobs polling every five minutes you are hammering your Git server for nothing. Webhooks are strictly better — Chapter 5. Keep polling as the fallback for repositories that cannot reach your Jenkins.
Verifying what you wrote
The configuration page shows a hint under the field: "Would last have run at Tuesday 12 August 2026 02:37:00; would next run at Wednesday…". Read it. It catches the classic day-of-week off-by-one.
The trigger-does-not-fire checklist
Did the pipeline run once since you added triggers? Same trap as parameters — Jenkins only learns the trigger by executing the Jenkinsfile.
Is the job disabled? A disabled job silently ignores triggers.
Is the controller's time zone what you assume? Check Manage Jenkins → System Information for user.timezone.
For pollSCM, check Git Polling Log on the job page — it says whether it saw a change.
Takeaway: always use H in the minute field, prefer webhooks to polling, and remember a new trigger needs one manual run before it takes effect.
propagate: false plus explicit handling is the pattern when you want to react to a failure rather than simply inherit it.
Note jobs.each rather than a for loop with a closure — a classic Groovy capture bug is building closures in a loop that all capture the final value of the variable. Using each gives each closure its own binding.
Upstream triggers
Instead of the upstream job pushing, the downstream job can pull:
This keeps knowledge of the dependency in the job that cares about it, which is often the better place for it.
When not to chain
Chained jobs lose the single view. If A → B → C are three jobs, nobody can see "did this commit make it to production?" in one place.
Prefer stages in one pipeline when the steps are one logical flow. Use separate jobs when they genuinely have different owners, schedules, or permissions — a deploy job that release managers can trigger independently is a good reason; "build and test are different things" is not.
Takeaway:build job: with propagate: false plus explicit result handling is the flexible form. Prefer stages over chained jobs unless ownership genuinely differs.
The alternative to one clever pipeline is several simple ones. A Multibranch job per service, each with Script Path pointing at services/api/Jenkinsfile, plus a path filter on the branch source so it only scans when relevant files change.
This is easier to reason about and easier for teams to own independently. The trade-off is more jobs to manage. For a monorepo with more than a handful of services, it is usually the better answer.
Takeaway: compute path filters with git diff origin/main...HEAD rather than trusting changeset, remember shared code invalidates everything, and consider a Jenkinsfile per service instead of one clever pipeline.
agent-ssh-key
Username: jenkins
Private Key: Enter directly, paste the private half
Add the node
Manage Jenkins → Nodes → New Node → name → Permanent Agent:
Field
Value
Number of executors
Start at the machine's core count, then tune
Remote root directory
/home/jenkins/agent
Labels
linux docker build — space-separated
Usage
Use this node as much as possible, or Only build jobs with label expressions matching this node
Launch method
Launch agents via SSH
Host
the hostname or IP
Credentials
the one you just created
Host Key Verification Strategy
Known hosts file — see below
Availability
Keep this agent online as much as possible
Host key verification
The options are:
Known hosts file — verifies against ~/.ssh/known_hosts on the controller. Correct.
Manually trusted key — trust on first use, then pin. Acceptable.
Manually provided key — paste the key. Most explicit.
Non verifying — accepts anything. Do not use this. It turns your agent connection into something an attacker on the network can trivially intercept, and the agent connection carries credentials.
For "Known hosts file", seed it as the Jenkins user on the controller:
IO-bound builds (lots of network, test suites waiting on databases): executors can exceed cores.
Memory-hungry builds: executors = RAM ÷ peak build memory. This is usually the real constraint. Four executors each running a JVM with a 4GB heap need 16GB plus headroom.
Over-provisioning executors produces builds that fail with OOM under load and pass when run alone — one of the most confusing failure modes there is. When in doubt, fewer.
Verifying and diagnosing
The node page shows Log — the launch transcript. Read it first when an agent will not connect.
Common failures:
Symptom
Cause
Server rejected the 1 private key
Wrong key, or public half not in agent's authorized_keys
Host key verification failed
known_hosts not seeded
java: command not found
Java not installed, or not on the non-interactive PATH
Remote root directory is not writable
Ownership of the remote root
Connects then immediately drops
Java version mismatch between controller and agent
For the PATH problem, set Node Properties → Tool Locations or specify the JavaPath in the advanced SSH settings. A non-login shell often has a much smaller PATH than your interactive one.
Takeaway: test the SSH connection by hand first, set the credential ID explicitly, never use "Non verifying" host key strategy, and size executors by memory rather than cores.
FROM jenkins/inbound-agent:latest-jdk21
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
make gcc python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
USER jenkins
A purpose-built agent image is the cleanest way to guarantee every build sees the same tools. It also makes "works on agent 2 but not agent 5" impossible by construction.
Docker agents per stage
You do not always need a persistent agent. With Docker available on a node, a pipeline can bring its own environment:
Each stage runs in a fresh container. No tool installation on the host, no version conflicts, and the environment is described in the repository.
Two practical notes:
The workspace is bind-mounted into the container, so files persist across stages on the same node.
The container runs as the Jenkins user's UID by default, which is what avoids root-owned files appearing in the workspace. If an image needs root, pass args '-u root' — and then clean up after yourself, because the next build will not be able to delete those files.
Takeaway: prefer WebSocket inbound agents — they need no extra port. Bake tools into an agent image, or use per-stage Docker agents so the environment lives in the repo.
Key points:
command: ["sleep"] / args: ["infinity"] — containers must stay alive so Jenkins can exec into them. An image with a normal entrypoint exits immediately and the build hangs. This is the single most common Kubernetes-agent mistake.
container('name') — chooses which container a step runs in. Outside any container block, steps run in the implicit jnlp container.
Resources — always set requests. Without them the scheduler cannot place pods sensibly and one build can starve the node.
Kaniko builds images without a Docker daemon, so you avoid mounting a socket or running privileged. On Kubernetes this is the right default.
defaultContainer saves wrapping every step. idleMinutes keeps the pod alive briefly after the build so a follow-up build reuses it — a real saving when pods take 30 seconds to schedule.
Debugging pods that never connect
kubectl -n jenkins-agents get pods
kubectl -n jenkins-agents describe pod <name>
kubectl -n jenkins-agents logs <name> -c jnlp
Symptom
Usual cause
Pod Pending forever
Resource requests exceed anything schedulable, or no node matches the selector
Pod starts, build stays queued
Jenkins URL not reachable from inside the cluster
ImagePullBackOff
Registry credentials missing — needs an imagePullSecret
Container exits immediately
Missing sleep infinity
Build hangs after pod ready
JNLP container cannot resolve the Jenkins service DNS
The jnlp container's log is where the connection error appears. Check it before anything else.
Takeaway: keep containers alive with sleep infinity, set resource requests, make sure the Jenkins URL resolves inside the cluster, and use Kaniko rather than mounting a Docker socket.
Live on the controller, in that build's directory.
Are deleted when the build finishes (unless preserveStashes).
Are unavailable to other builds or other jobs.
For anything that outlives the build, use archiveArtifacts (Chapter 8) or a real artifact repository.
Stash is also not for large files. Everything goes through the controller's disk and network. Stashing a 2GB build output means uploading 2GB to the controller and downloading it again — often slower than rebuilding, and it puts controller disk in the path of every build.
Rough guidance: stash tens of megabytes freely, hundreds with care, gigabytes never. For large artifacts use S3, a registry, or a shared filesystem.
Keeping stashes for Restart from Stage
options {
preserveStashes(buildCount: 5)
}
Without this, restarting the deploy stage of an old build fails because its stash is gone.
The alternatives
Shared workspace on the same node — if stages can run on one agent, they share the workspace and you need none of this. Simplest when it works.
External artifact storage:
stage('Build') {
steps {
sh 'make build'
sh 'aws s3 cp dist/app.tar.gz s3://ci-artifacts/${BUILD_TAG}/'
}
}
stage('Deploy') {
steps {
sh 'aws s3 cp s3://ci-artifacts/${BUILD_TAG}/app.tar.gz .'
sh 'make deploy'
}
}
More moving parts, but it scales and keeps controller disk out of the data path. There is also an Artifact Manager on S3 plugin that transparently redirects both archiveArtifacts and stash to S3 — worth knowing about on a busy controller.
Takeaway: stash for small files between agents in one build, artifacts or S3 for anything larger or longer-lived. Remember stashes flow through the controller.
Node page → Mark this node temporarily offline, with a reason. Running builds finish; no new ones start. Then patch, reboot, and bring it back.
From a script:
def c = Jenkins.instance.getNode('build-agent-3').toComputer()
c.setTemporarilyOffline(true, new hudson.slaves.OfflineCause.UserCause(null, 'patching'))
// later
c.setTemporarilyOffline(false, null)
Always give a reason. An agent that is offline with no explanation is a mystery someone will spend an afternoon on.
Takeaway: disk is the usual outage — clean workspaces, prune Docker, set thresholds. Beat drift with immutable agents, and always mark nodes offline with a reason.
For a username/password this creates three variables:
REGISTRY_CREDS user:password
REGISTRY_CREDS_USR user
REGISTRY_CREDS_PSW password
Convenient, but the secret is in the environment for the whole pipeline, including stages that have no business seeing it. Prefer withCredentials when the secret is only needed in one place.
The four ways to leak a secret
1. Groovy interpolation.
sh "curl -H 'Authorization: Bearer ${TOKEN}'" // BAD
sh 'curl -H "Authorization: Bearer $TOKEN"' // GOOD
Double quotes interpolate in Groovy, so the secret becomes part of the command string — visible in the console if the command is echoed, and visible in ps on the agent. Single quotes leave $TOKEN for the shell.
Jenkins now warns: "Warning: A secret was passed to an interpolated string". Do not ignore it.
2. Command lines. Even without interpolation, a secret as an argument is visible to any process on the agent:
docker login -u user -p "$PASSWORD" # visible in ps
echo "$PASSWORD" | docker login -u user --password-stdin # not
Almost every tool has a stdin or file-based option. Use it.
3. set -x. Shell tracing prints every expanded command, secrets included.
set -euo pipefail # fine
set -euxo pipefail # will print your secrets
Jenkins' default sh already runs with -x, which is why an explicit shebang matters:
sh '''#!/bin/bash
set -euo pipefail
…
'''
4. Writing them down. Secrets that reach a file in the workspace get archived, stashed, and kept. If you must write one, write it outside the workspace and delete it in post.
Masking is a safety net, not a control
Jenkins masks known credential values in console output, showing ****. It is genuinely useful and it is not sufficient:
It only masks exact matches. Base64-encode it, split it, or let a tool reformat it and the mask does not apply.
It does not mask what leaves the machine — a secret POSTed to a third party is gone.
It does not protect against another process on the agent reading /proc/<pid>/cmdline.
Treat masking as a way to catch mistakes, not as the thing that keeps secrets safe.
Worth running against a new pipeline once before it goes near production credentials.
Takeaway: single-quote any shell command containing a secret, pipe secrets to stdin rather than passing them as arguments, avoid set -x, and treat console masking as a backstop.
<useSecurity>false</useSecurity>
and delete the <authorizationStrategy> and <securityRealm> elements. Then:
sudo systemctl start jenkins
Jenkins comes up with no security at all. Fix the configuration through the UI, then turn security back on immediately. Do not leave it in this state for "just a few minutes" on a reachable network.
Hardening the login itself
Put Jenkins behind SSO with MFA if you can.
If using Jenkins' own database, there is no built-in lockout or rate limiting — put it behind a proxy that provides them, or use the OWASP Dependency-style plugins that add throttling.
Never expose a Jenkins with useSecurity=false to a network you do not control. Unauthenticated Jenkins instances are scanned for continuously, and the script console is the payload.
Takeaway: disable open sign-up, prefer SSO, use API tokens for scripts, and know the config.xml lockout recovery before you need it.
Agent/Configure, Agent/Connect
Manage agents
Three of these are more powerful than they look:
Job/Configure lets someone add sh 'cat /etc/passwd'. If the job runs on an agent with credentials, that is credential access. Job configuration is code execution.
Run/Replay is the same thing without needing configure — the user supplies arbitrary pipeline script.
Overall/Administer is total compromise. Grant it to as few people as possible and audit the list.
Role-based strategy in practice
Install Role-based Authorization Strategy, select it, then Manage Jenkins → Manage and Assign Roles.
Assign Roles → attach users or LDAP groups to roles.
The pattern is a regular expression against the full job name, which is why folders matter — team-payments/.* only works if the jobs live in a folder called team-payments.
A sane starting point
Everyone authenticated: Overall/Read
Developers: Job/Read, Job/Build, Job/Cancel on their folder
Team leads: + Job/Configure, Job/Create, Credentials/View on their folder
Platform team: Overall/Administer
Anonymous: nothing (or Overall/Read if you want a public dashboard)
Anonymous access
Anonymous users get their own row in the matrix. Granting Overall/Read makes the dashboard public — sometimes desirable for an open-source project, and it also exposes job names, build logs and console output to the internet. Build logs routinely contain internal hostnames, paths, and occasionally secrets.
Decide deliberately. If in doubt, grant nothing.
Takeaway: Job/Configure and Run/Replay are code execution; Overall/Administer is root. Use folders plus the role strategy so teams can only reach their own jobs.
Use instead
new File(path).text
readFile(path)
new File(path).write(s)
writeFile file: path, text: s
System.getenv('X')
env.X
JsonSlurper
readJSON
new URL(u).text
httpRequest step, or sh 'curl …'
Arbitrary Runtime.exec
sh / bat
Date formatting gymnastics
new Date().format(...) is usually approvable; or do it in shell
Reaching for java.io.File in a Jenkinsfile is almost always a sign you want readFile/writeFile, which also work correctly on a remote agent — new File() runs on the controller, which is rarely what the author intended.
Trusted code
Two kinds of Groovy skip the sandbox entirely:
Global shared libraries configured at Manage Jenkins → System → Global Pipeline Libraries run trusted and unsandboxed. Anyone who can push to that library repository can run arbitrary code on your controller. Protect it like production: branch protection, required review, restricted write access.
Init scripts in $JENKINS_HOME/init.groovy.d/ run as the system at startup.
Library code loaded dynamically with library() in a Jenkinsfile is sandboxed; library code configured globally is not. That distinction is the whole security boundary, and it is easy to miss.
Do not disable the sandbox
There is a per-job "Use Groovy Sandbox" checkbox on inline pipeline scripts. Unchecking it requires Administer and runs the script unsandboxed.
If you find yourself wanting to, the honest options are: move the logic into a shell script, or move it into a reviewed shared library. Both are better than a permanently unsandboxed job that anyone with Configure can edit.
Takeaway: read script approvals before granting them — some are equivalent to handing over the controller. Global shared libraries are unsandboxed, so treat write access to them as administrator access.
15. Rotate secrets on any suspicion. If Jenkins was exposed unauthenticated, or a plugin RCE landed before you patched, assume every credential in the store is compromised and rotate. There is no partial version of this.
A quick self-assessment
Run through these questions:
Can an anonymous user reach /script? (Should be: no.)
How many users have Overall/Administer? (Should be: few, and you can name them.)
Does the built-in node have executors? (Should be: no.)
When was the last plugin update? (Should be: weeks, not years.)
Can a job in team A's folder use team B's credentials? (Should be: no.)
Has the backup ever been restored? (Should be: yes.)
Do fork pull requests run on agents that hold credentials? (Should be: no.)
Any "I'm not sure" on that list is worth an afternoon.
Takeaway: patch plugins, keep builds off the controller, scope credentials to folders, restrict Administer, and never expose Jenkins to the internet without SSO. Those five cover most of the risk.
Recording the approver on a deploy — as in the input note in Chapter 4 — is worth doing routinely:
currentBuild.description = "deployed by ${approver} to ${params.ENVIRONMENT}"
That description shows in the build list, which turns the job page into a deployment log for free.
Correlating a deploy with an incident
The API makes this scriptable:
# every deploy in the last day, with who and what
curl -s -u "$USER:$TOKEN" \
"https://ci.example.com/job/deploy-prod/api/json?tree=builds[number,timestamp,result,description,actions[causes[userId]]]{0,50}" \
| jq -r '.builds[] | "\(.number)\t\(.timestamp/1000|todate)\t\(.result)\t\(.description // "-")"'
Keeping that answerable in one command is worth the small effort of setting build descriptions.
What Jenkins does not give you
No native change approval. Anyone with Configure changes a job immediately. If you need review, keep configuration in code — Jenkinsfiles in repositories, controller config in JCasC (Chapter 10) — so changes go through pull requests.
No secret access log. Jenkins does not record which build read which credential. If you need that, use an external secret manager, which does.
Both gaps push in the same direction: put configuration in version control. That is where review, history and attribution already work.
Takeaway: install Audit Trail and Job Configuration History on day one, set build descriptions so the job page doubles as a deploy log, and move configuration into git where review actually exists.
What you get
Test Result on the build page: pass/fail counts, and every failure with its stack trace.
Test Result Trend graph on the job page — the shape of your suite over time.
Age of a failure: "failing for 4 builds". Distinguishes a new break from a long-standing one.
Flaky test detection with the Flaky Test Handler plugin.
Unstable versus failed
By default, failing tests mark the build UNSTABLE (yellow), not FAILURE (red) — because the build itself succeeded; the tests reported problems.
post {
always {
junit 'reports/*.xml'
script {
if (currentBuild.result == 'UNSTABLE') {
error('Tests failed')
}
}
}
}
My preference: failing tests should fail the build. Yellow is a colour people learn to ignore, and a suite that is permanently yellow provides no signal at all. Reserve UNSTABLE for genuinely advisory things.
Coverage
The Coverage plugin (which supersedes Cobertura and JaCoCo plugins) reads several formats:
"80% of the lines you touched must be covered" is enforceable on a legacy codebase. "80% overall" is not, and gets disabled within a month.
Takeaway: publish JUnit XML in post { always } with allowEmptyResults: false, and gate coverage on modified lines rather than the project total.
type: 'NEW' is the one that works on a real codebase. "Zero warnings" is unachievable on anything with history; "no new warnings" is achievable today and improves the codebase monotonically. This single setting is the difference between a quality gate people respect and one they disable.
What you get
A trend graph of issue counts.
Annotated source — the warning shown against the line that caused it.
New / Fixed / Outstanding breakdown per build.
Blame — which commit introduced each issue, from git.
Security scanning in the pipeline
The same shape works for security tools. Since Jenkins itself is often the most privileged system you own, scanning what it builds matters.
Fail on new findings, not total findings. Same reasoning as lint. A gate that fires on inherited debt gets bypassed.
Secret scanning belongs in CI and pre-commit. By the time gitleaks finds a key in CI it is already in the repository's history and must be rotated, not just removed. CI is the backstop, not the control.
Requires an NVD API key on recent versions, configured at Manage Jenkins → Tools → Dependency-Check. Without one the database update is rate-limited to the point of uselessness.
Takeaway: gate on new issues rather than totals — it is the only threshold that survives contact with a real codebase. Add || true so the plugin, not the tool's exit code, decides the result.
sandbox;
Publish the report somewhere else entirely — S3 static hosting, GitHub Pages, your artifact repository — and link to it from the build description.
The second is what I would do for anything with more than a handful of users.
Requires the Allure plugin plus the commandline tool configured at Manage Jenkins → Tools.
Takeaway:publishHTML works but fights the CSP for good reasons. For anything shared, publish reports to external static hosting and link to them instead.
Nobody minds a gate that fails in twenty seconds. Everyone minds one that fails after forty minutes.
3. Make the message actionable.
script {
def result = sh(script: 'make lint', returnStatus: true)
if (result != 0) {
error("""
Lint failed.
Run 'make lint-fix' locally to fix most of these automatically.
Full report: ${env.BUILD_URL}Lint_20Report/
""".stripIndent())
}
}
4. Have a documented override. A gate with no override gets removed the first time it blocks a genuine emergency. A gate with one survives, because the escape hatch is visible and audited:
stage('Coverage gate') {
when {
not { expression { env.CHANGE_TITLE?.contains('[skip-coverage]') } }
}
steps { sh 'make coverage-check' }
}
Then review uses of [skip-coverage] periodically. Visible, audited bypass beats invisible, unaudited removal.
Fast, cheap checks first; expensive ones behind a when; every gate on the delta rather than the total.
Takeaway: gate on what changed, fail in seconds not minutes, make the error say what to do, and give the gate a visible audited bypass so it never gets deleted outright.
4. Skip what did not change. Chapter 5's monorepo filtering.
5. Docker layer caching. A Dockerfile that copies the lockfile and installs dependencies before copying source means a code change does not reinstall dependencies:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
6. Do not rebuild for a deploy. Build once, promote the same artefact through environments. Rebuilding per environment is both slow and unsafe — you are deploying something you did not test.
Things that look like wins and are not
More executors on an under-provisioned agent. You get contention and OOM, not speed.
Removing tests. Obviously. But worth saying, because it is what happens when the pipeline is slow enough.
Caching everything. A cache that is wrong is worse than no cache — a stale dependency produces a failure that reproduces nowhere else. Always key the cache on a lockfile.
Set a timeout so slow is visible
options {
timeout(time: 30, unit: 'MINUTES')
}
A pipeline with no timeout can hang for days holding an executor. A timeout turns "mysteriously stuck" into a clear failure.
Takeaway: measure with wfapi/describe before optimising. Shallow clone and a lockfile-keyed dependency cache are usually the two biggest wins, and neither takes long.
java.util.regex.Matcher is not serialisable and is still in scope across the sh boundary. Two fixes:
// 1. Confine it to a @NonCPS method
@NonCPS
def extractVersion(String text) {
def m = (text =~ /VERSION=(.*)/)
return m ? m[0][1] : null
}
// 2. Or null it before the next step
script {
def matcher = (readFile('version.txt') =~ /VERSION=(.*)/)
def version = matcher ? matcher[0][1] : null
matcher = null
sh "echo ${version}"
}
@NonCPS means "run as ordinary Groovy, do not checkpoint". Such methods cannot call Pipeline steps (sh, echo) and should return something simple.
The same bites with JsonSlurper:
def json = new groovy.json.JsonSlurper().parseText(text) // LazyMap — not serialisable
def json = readJSON text: text // pipeline-native, safe
Prefer readJSON, readYaml, writeJSON. They exist precisely because of this.
String interpolation and secrets
sh "curl -H 'Authorization: Bearer ${TOKEN}' https://api.example.com" // BAD
sh 'curl -H "Authorization: Bearer $TOKEN" https://api.example.com' // GOOD
Double quotes interpolate in Groovy, baking the secret into the command string — which can reach the console and process listings. Single quotes hand the string to the shell, which expands $TOKEN itself. Jenkins warns about this; take it seriously. More in Chapter 7.
Takeaway: use script sparingly, move real logic into shell scripts, prefer readJSON over JsonSlurper, and use single quotes when a secret is involved.
Default to shell scripts and images. Reach for a library when you genuinely need Jenkins-side logic — interacting with the build, the credential store, or the pipeline itself.
The security dimension
A global shared library runs trusted and unsandboxed (Chapter 7). Anyone who can push to that repository can run arbitrary code on your controller, as the Jenkins user, with access to every credential.
Treat write access to the library repository as equivalent to Jenkins administrator. Branch protection, required review, and a small list of people with merge rights.
Takeaway: libraries are for logic that must live inside Jenkins. Everything else belongs in a shell script or a container image. And write access to a global library is administrator access.
v3
Load implicitly
Off, unless you want it in every pipeline automatically
Allow default version to be overridden
On — lets a pipeline pin its own version
Include @Library changes in job recent changes
On
Retrieval method
Modern SCM → Git → repo URL + credentials
Default version is the important decision. Pointing at main means every pipeline in the company changes the moment you merge. Pointing at a tag means you roll out deliberately.
Loading it in a Jenkinsfile
// pinned to the default version
@Library('ci-lib') _
// a specific tag or branch
@Library('ci-lib@v3') _
@Library('ci-lib@feature/new-deploy') _
// several
@Library(['ci-lib@v3', 'security-lib@v1']) _
pipeline {
…
}
The trailing _ is required — the annotation must attach to something, and _ is a throwaway. Omitting it produces a confusing error.
Dynamic loading, which is sandboxed rather than trusted:
library 'ci-lib@v3'
Use this for libraries you do not fully trust. It runs under the Groovy sandbox, so it cannot reach the controller's internals.
Folder-level libraries
A library can be scoped to a folder: Folder → Configure → Pipeline Libraries. Only jobs in that folder can load it, and it can be managed by that team rather than by administrators. This is the right level for a team's own helpers.
Versioning strategy
main ← development
v1, v2, v3 ← tags pipelines pin to
Pipelines use @Library('ci-lib@v3'). Breaking changes cut v4, and repos migrate when they choose. Without this, a library change breaks every build in the company simultaneously — and you will do that exactly once.
Takeaway:vars/ for callable steps, src/ for classes, resources/ for files. Pin the default version to a tag, not to main.
The [defaults] << config idiom is the standard way to merge caller options over defaults. Validate required inputs and error with a message naming the step — a failure deep in a library is otherwise very hard to trace.
Powerful, and a real trade-off: teams lose the ability to do anything unusual without changing the library. Provide escape hatches (extra stages passed as closures) or expect to be asked constantly.
Documentation
A vars/buildAndPush.txt file next to the .groovy is rendered as markdown in Pipeline Syntax → Global Variable Reference. Write it — a library nobody can discover gets reimplemented.
Takeaway: take a Map and merge over defaults, validate inputs with a clear error, and document each step in a matching .txt file.
import com.example.ci.Docker
def d = new Docker(this) // `this` is the pipeline
d.build('api:42')
echo d.digest('api:42')
new Docker(this) is the standard idiom. The field must not be static, and the class must be serialisable.
CPS, and why Groovy behaves strangely
Pipeline Groovy is transformed into a continuation-passing form so a build can pause and resume across a restart. This transformation does not cover everything, and the gaps are surprising.
Closures in some iteration methods misbehave:
// May not work as expected inside CPS
[1, 2, 3].collect { it * 2 }
// Reliable
def out = []
for (int i = 0; i < list.size(); i++) {
out << list[i] * 2
}
In practice each and collect usually work; inject, sort with a comparator, and nested closures are where it breaks down. If iteration behaves inexplicably, that is the reason.
Ideal for pure data transformation, which is exactly where CPS causes trouble.
Non-serialisable objects across step boundaries — the Matcher and JsonSlurper problems from Chapter 4 apply equally here. Prefer readJSON/readYaml, and null out anything exotic before the next step.
That pattern — ship a shell script from the library, run it — gets you the sharing benefit while keeping the logic in a language you can test and run locally. It is often the best of both.
Takeaway:implements Serializable on every class, pass this to reach pipeline steps, use @NonCPS for pure data work, and prefer shipping shell scripts via resources/ over writing complex Groovy.
Level 3: JenkinsPipelineUnit
Mocks the pipeline steps so you can assert on what a library would do.
Wire the library repo's own pipeline to trigger the canary. If the canary goes red, do not tag a new version. This catches integration problems that unit tests cannot.
Level 5: Replay
For quick iteration, Replay (Chapter 4) lets you edit library files inline and run immediately. The fastest inner loop there is — just remember to commit the result.
A practical policy
Pure logic in src/ → Spock tests, run on every commit.
vars/ steps → JenkinsPipelineUnit for the important ones, not all.
A canary repo on main.
Tag vN only when the canary is green.
That is proportionate. Full coverage of Groovy pipeline code is rarely worth its cost; a canary catches most of what matters.
Takeaway: put logic in src/ classes so it is testable as plain Groovy, use JenkinsPipelineUnit for critical steps, and keep a canary repository that must be green before you tag a version.
Declarative's matrix needs its axes known up front. Scripted can compute them.
Full try/catch/finally around anything, not just inside a script block.
What you lose
options, when, post, parameters as declarative directives — you implement them by hand.
Restart from Stage — Declarative only.
Better Blue Ocean and Stage View rendering.
The structural validation that catches mistakes at parse time.
Readability. A Scripted pipeline can be anything, which means reading one tells you less.
Mixing
You can drop into Scripted inside Declarative via script {} (Chapter 4). You cannot do the reverse. This is why the standard advice is: Declarative outer, script blocks where needed — you keep the structure and the tooling, and still get Groovy where it is genuinely required.
That try/catch/finally is what Declarative's post does for you. Writing it correctly every time, in every pipeline, is the argument for Declarative.
The honest recommendation
Use Declarative. Use Scripted only when you genuinely need runtime-generated stages or top-level control flow that script {} cannot express.
If you inherit Scripted pipelines, they are not urgent to migrate — they work. Convert when you touch one anyway, and take Restart from Stage as the payoff.
Takeaway: Declarative for structure and tooling, Scripted for runtime-generated stages. Mixing via script {} covers almost every real case, and only Declarative gets Restart from Stage.
In Docker, pin the tag rather than tracking lts:
image: jenkins/jenkins:2.555.3-lts-jdk21 # reproducible
# not
image: jenkins/jenkins:lts # moves under you
Tracking a floating tag means a docker compose pull on an unrelated day silently upgrades Jenkins. Pin it, and upgrade deliberately.
Learning or testing — Docker. Disposable and quick.
A real server you own — the apt/dnf package. Boring and well-trodden.
You already run Kubernetes — the Helm chart with JCasC.
Air-gapped — the WAR plus manually downloaded .hpi plugin files.
Checking Java
The most common upgrade blocker. Confirm what Jenkins is actually running on — not what java -version says in your shell, which may differ from the service's:
Or Manage Jenkins → System Information → java.version.
Takeaway: use the LTS line, pin the version so upgrades are deliberate, and set JENKINS_HOME explicitly if you run the WAR directly.
The three that people miss: X-Forwarded-Proto (otherwise Jenkins builds http:// links), the Upgrade/Connection pair (otherwise WebSocket agents fail), and proxy_buffering off (otherwise console output arrives in lumps).
Caddy, if you want this to be four lines
ci.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy sets the forwarded headers correctly and gets certificates automatically. For a personal instance it is hard to argue with.
Then tell Jenkins
Manage Jenkins → System → Jenkins Location → Jenkins URL must be https://ci.example.com/.
Jenkins checks this itself. If it disagrees with reality you get a red banner on Manage Jenkins. Click it — the diagnostic actually tells you which header is wrong.
Bind Jenkins to localhost only
Once a proxy is in front, Jenkins should not be reachable directly on 8080 from outside.
Then confirm from another machine that http://<host>:8080 no longer answers. A proxy in front of a still-exposed backend is decoration, not security.
Verify
# certificate and redirect
curl -sI http://ci.example.com | head -3
curl -sI https://ci.example.com | head -3
# Jenkins should see the right scheme
curl -s https://ci.example.com/api/json | head -c 200
Takeaway: terminate TLS at a proxy, forward X-Forwarded-Proto and the WebSocket upgrade headers, set the Jenkins URL to match exactly, and bind Jenkins itself to localhost.
JUnit
Parses test XML into trend graphs.
Warnings Next Generation
Parses compiler/linter output into trends.
Configuration as Code (JCasC)
Whole controller config from YAML. Chapter 10.
Role-based Authorization Strategy
Real permissions. Chapter 7.
Pipeline: Stage View
Stage timing grid on the job page.
How the plugin system bites
1. Dependency chains. Installing one plugin pulls five. Uninstalling is often blocked because something depends on it. Check Installed → the dependency column before removing anything.
2. Update-in-place with no rollback. Jenkins does not roll back plugin upgrades. The recovery path is to downgrade manually:
# plugins live here
ls /var/lib/jenkins/plugins/
# a plugin is <name>.jpi plus an unpacked <name>/ directory
sudo systemctl stop jenkins
sudo rm -rf /var/lib/jenkins/plugins/git.jpi /var/lib/jenkins/plugins/git/
# drop the older .hpi in, renamed to .jpi
sudo systemctl start jenkins
Which is exactly why you snapshot JENKINS_HOME before an upgrade.
3. Abandoned plugins. Many are unmaintained. Before depending on one, check its page on plugins.jenkins.io for the last release date and whether it is marked deprecated.
4. Security advisories. Plugins are the main source of Jenkins CVEs. Manage Jenkins → Plugins → Updates flags known-vulnerable versions, and the banner at the top of the dashboard warns you. Do not ignore it — Jenkins plugin RCEs are actively exploited, and a Jenkins controller is a machine holding every credential your organisation deploys with.
Auditing what you have
The script console gives you a quick inventory. Manage Jenkins → Script Console:
Be aware the script console runs arbitrary Groovy as the Jenkins user. It is the most powerful page in the product and the first thing an attacker looks for.
Takeaway: plugins are how Jenkins does everything, and also how Jenkins gets compromised. Install deliberately, keep them updated, and snapshot JENKINS_HOME before you upgrade.
Script Console — a Groovy REPL running as the Jenkins user with full privileges. Extraordinarily useful, and the crown jewel for anyone attacking your instance. See its own note below.
Prepare for Shutdown — stops new builds starting, lets running ones finish. Do this before maintenance instead of pulling the plug.
The URLs worth memorising
URL
Does
/manage
Manage Jenkins
/script
Script console
/log
System log
/systemInfo
System information
/safeRestart
Restart after builds finish
/exit
Shut down
/api/json?pretty=true
Machine-readable everything
/whoAmI
Who Jenkins thinks you are, and your authorities
/whoAmI is genuinely useful when debugging permissions — it shows the exact authorities your session carries.
Takeaway: System Log with a custom logger and System Information are the two administration pages that solve real problems. The rest you visit once during setup.
Build Steps
Execute shell (Linux) or Execute Windows batch command.
Invoke top-level Maven targets, Invoke Gradle script, and whatever your plugins add.
Post-build Actions
Archive the artifacts — keeps files with the build.
Publish JUnit test result report — parses XML into trends.
E-mail Notification, Editable Email Notification.
The shell step, and the trap in it
#!/bin/bash
set -euo pipefail
echo "Building ${JOB_NAME} #${BUILD_NUMBER}"
make clean
make all
make test
The trap: Jenkins runs /bin/sh with -ex by default, so it exits on the first failing command — but only for top-level commands. A failure inside a pipeline (a | b) or a subshell is invisible without pipefail. Always start with an explicit shebang and set -euo pipefail. Without it, a build where the tests failed can be reported blue.
The full list for a running build is at <build-url>/injectedEnvVars if the EnvInject plugin is present, or just run env | sort as a build step.
Why to move off Freestyle
Configuration lives in Jenkins, not your repo. No review, no history, no diff. Someone changes a text box and the build changes with no record.
No branching logic without chaining jobs together.
Not portable. Rebuilding the job elsewhere means re-typing the form.
No Replay, no Restart from Stage.
Converting to Pipeline
There is no reliable automatic conversion. Do it by hand: read the Freestyle config, write a Jenkinsfile that does the same, run both side by side until they agree, then delete the Freestyle job. Most Freestyle jobs are 20 lines of Pipeline.
Takeaway: Freestyle is the form-based original. Know it because you will inherit it, always set -euo pipefail in shell steps, and migrate to Pipeline when you touch a job anyway.
/scriptText returns plain text rather than HTML — the right endpoint for automation.
Now the security part
This page is remote code execution as a feature. Anyone who reaches it can read every credential, write files as the Jenkins user, and pivot into everything Jenkins can deploy to. Essentially every "Jenkins was compromised" story ends here.
Consequences to internalise:
Overall/Administer is not a role to hand out casually. It is script console access, which is root-equivalent on the controller.
Never paste a script you do not understand. "Run this in your script console to fix X" is a working social-engineering technique.
Audit it. Script console use is logged; make sure someone reads that log.
If a Jenkins instance was ever exposed to the internet without authentication, treat every credential in it as compromised and rotate. Not "probably fine" — rotate.
Takeaway: the script console is the most powerful and most dangerous page in Jenkins. Learn the useful snippets, and treat Administer permission as equivalent to root on the controller.
The = in groovy = means "read the script from stdin". It is easy to miss and the command fails confusingly without it.
Bulk operations
Where the CLI earns its place. Adding a build discarder to fifty jobs by hand is an afternoon; scripted it is a minute:
for job in $($CLI list-jobs); do
$CLI get-job "$job" > "/tmp/${job}.xml"
done
Then edit the XML and push back with update-job. Always keep the originals — get-job output is your rollback.
Export and re-import a job between instances
# from the old instance
java -jar jenkins-cli.jar -s https://old-ci.example.com -auth $A get-job api-build > api-build.xml
# to the new one
java -jar jenkins-cli.jar -s https://new-ci.example.com -auth $B create-job api-build < api-build.xml
Credentials do not travel with the job — only the credential IDs it references. Create those on the target first, with the same IDs, or the imported job fails at checkout. This is the main reason to set credential IDs by hand (Chapter 7).
The REST alternative
Everything above is also possible over HTTP, which avoids needing Java on the machine:
# trigger a build
curl -X POST -u "$JENKINS_USER_ID:$JENKINS_API_TOKEN" \
"$JENKINS_URL/job/api-build/build"
# fetch a job's config
curl -s -u "$JENKINS_USER_ID:$JENKINS_API_TOKEN" \
"$JENKINS_URL/job/api-build/config.xml" -o api-build.xml
# push it back
curl -X POST -u "$JENKINS_USER_ID:$JENKINS_API_TOKEN" \
-H "Content-Type: application/xml" \
--data-binary @api-build.xml \
"$JENKINS_URL/job/api-build/config.xml"
For POSTs you may need a CSRF crumb (Chapter 3). Use the REST API in CI scripts and containers; use the CLI interactively.
A note on CLI over remoting
Older Jenkins exposed the CLI over a binary remoting protocol, which had a poor security history and is disabled by default now. The modern CLI runs over HTTP/HTTPS through the same authentication as the UI. If you find -remoting in an old runbook, that is why it no longer works — and you should not re-enable it.
Takeaway: use API tokens with the CLI, get-job/update-job for bulk edits with the originals kept as rollback, and remember credentials do not travel with an exported job.
Skip workspace/, caches/, and the unpacked plugin directories. On a busy controller these are most of the bytes and none of the value.
Run it from cron, or as a Jenkins job — with the caveat that a Jenkins job cannot back up a Jenkins that is already down.
Excluding build history
On a large instance jobs/*/builds dwarfs everything else. If you can accept losing build history in a disaster, exclude it and your backup shrinks by orders of magnitude:
--exclude="${JENKINS_HOME}/jobs/*/builds"
Decide deliberately. Losing history is usually survivable; losing job configuration and credentials is not.
If you excluded unpacked plugin directories, Jenkins re-expands them from the .jpi files on start.
The part everyone skips
Test the restore. A backup you have never restored is a hypothesis, not a backup.
Once a quarter: restore to a throwaway VM or container, start it, log in, confirm jobs and credentials are there. Thirty minutes, and it is the only thing that turns a backup into a recovery plan.
Failure modes this catches, all of which are common:
secrets/ excluded by an over-eager exclusion pattern → credentials decrypt to garbage.
Backup taken while Jenkins was writing → corrupt XML.
Ownership wrong after extraction → Jenkins will not start.
The archive has been zero bytes for four months and nobody noticed.
A safety net for the last one
# alert if the newest backup is old or tiny
newest=$(find /backup/jenkins -name '*.gpg' -printf '%T@ %s %p\n' | sort -rn | head -1)
age=$(( ($(date +%s) - ${newest%% *}) / 3600 ))
size=$(echo "$newest" | awk '{print $2}')
[ "$age" -lt 26 ] && [ "$size" -gt 1000000 ] || echo "JENKINS BACKUP PROBLEM: age=${age}h size=${size}"
Takeaway: back up JENKINS_HOME minus workspaces, always include secrets/ with credentials.xml, encrypt it, and restore it once a quarter to prove it works.
Plugins go through Manage Jenkins → Plugins → Updates. Select all, install, restart.
When it will not start
The log is definitive:
sudo journalctl -u jenkins -n 200 --no-pager
# only if this install was explicitly configured to write a log file
sudo tail -200 /var/log/jenkins/jenkins.log 2>/dev/null || true
Failure to load a plugin is the usual cause. Jenkins names it. Move it aside:
apt-cache policy jenkins # what versions exist
sudo apt-get install jenkins=2.452.3 # pin an older one
Downgrading a plugin — the UI offers it if a previous version is known; otherwise drop the older .hpi into plugins/ renamed to .jpi and restart.
Safe mode
There is no single "safe mode" flag. Isolate the plugins by hand:
sudo systemctl stop jenkins
# keep a copy you can put back
sudo cp -a /var/lib/jenkins/plugins /var/lib/jenkins/plugins.broken
# move everything aside, then restore plugins a few at a time
sudo mkdir -p /var/lib/jenkins/plugins.parked
sudo mv /var/lib/jenkins/plugins/* /var/lib/jenkins/plugins.parked/
sudo systemctl start jenkins # bare Jenkins — enough UI to fix configuration
Then re-add plugins in batches, restarting between, until the offender reappears.
A note on --enable-future-java: you will see this flag suggested for recovery. It is unrelated to plugins — it lets Jenkins start on a Java version newer than the release line officially supports. Useful if you upgraded the JVM ahead of Jenkins; useless for a plugin problem.
Staging
The only reliable way to de-risk a large instance:
# clone production config into a container, minus history
sudo tar -czf /tmp/home.tgz \
--exclude='*/workspace' --exclude='*/builds' \
-C /var/lib jenkins
docker run -d --name jenkins-staging -p 8081:8080 \
-v jenkins_staging:/var/jenkins_home jenkins/jenkins:lts-jdk21
docker cp /tmp/home.tgz jenkins-staging:/tmp/
docker exec jenkins-staging tar -xzf /tmp/home.tgz -C /var/jenkins_home --strip-components=1
docker restart jenkins-staging
Upgrade staging first. It costs an hour and catches the plugin that breaks your pipelines.
Cadence
Security advisories — as soon as practical. Plugin RCEs get exploited quickly.
Plugins — monthly, in a batch, after a backup.
Core LTS — quarterly, following the LTS line.
Do not let it drift for a year. A twelve-version jump has compounding breakage and no working rollback path; four small upgrades are far less risky than one large one.
Takeaway: fresh backup, core before plugins, read the changelog, and keep a staging copy for anything large. Never let the gap grow to years.
#!/bin/bash
# agents that should be online but are not
curl -sf -u "$USER:$TOKEN" \
"https://ci.example.com/computer/api/json?tree=computer[displayName,offline,temporarilyOffline]" \
| jq -r '.computer[] | select(.offline and (.temporarilyOffline|not)) | .displayName' \
| while read -r n; do echo "AGENT OFFLINE: $n"; done
# queue older than 30 minutes
curl -sf -u "$USER:$TOKEN" "https://ci.example.com/queue/api/json" \
| jq -r --argjson cutoff "$(( $(date +%s%3N) - 1800000 ))" \
'.items[] | select(.inQueueSince < $cutoff) | "STUCK IN QUEUE: \(.task.name) — \(.why)"'
That second one is genuinely valuable — .why is Jenkins telling you in English why something cannot run, which is usually a label that no longer matches any agent.
Disk on the controller
The most common Jenkins outage. Build history and artifacts grow without limit.
du -sh /var/lib/jenkins/jobs/*/builds | sort -h | tail -20
Fixes, in order of effect: set buildDiscarder on every job (Chapter 4), split artifactNumToKeepStr from numToKeepStr (Chapter 8), and move artifacts to S3.
A global default for jobs that forget:
// script console — apply a discarder everywhere it is missing
import jenkins.model.BuildDiscarderProperty
import hudson.tasks.LogRotator
Jenkins.instance.getAllItems(Job.class).each { job ->
if (job.metaClass.respondsTo(job, 'setBuildDiscarder') && !job.buildDiscarder) {
job.setBuildDiscarder(new LogRotator(-1, 50, -1, 5))
job.save()
println "set discarder on ${job.fullName}"
}
}
JVM heap
Symptoms of too little: UI slow under load, long GC pauses, OutOfMemoryError in the log.
Rough sizing: 2GB baseline, plus about 1GB per 100 jobs, plus headroom for large build histories. Setting -Xms equal to -Xmx avoids heap resizing pauses.
Logging what matters
Manage Jenkins → System Log → Add new log recorder — scope a logger to a package and raise its level. Far better than turning up global logging:
Logger
For
hudson.plugins.git
Checkout problems
com.cloudbees.jenkins.GitHubWebHook
Webhooks not firing
hudson.slaves
Agent connection issues
org.jenkinsci.plugins.workflow
Pipeline internals
hudson.security
Authentication and authorisation
Set to FINE, reproduce, read, then set it back. FINE logging on a busy controller produces a lot of output.
Takeaway: alert on queue depth, offline agents and controller disk. The queue's .why field usually names the problem in plain English.
nvm
rbenv
sdkman
sh 'env | sort'
Shallow clone.git describe and git diff origin/main...HEAD need history and tags (Chapter 5).
A dirty workspace. Try cleanWs().
Different user. The Jenkins user has a different HOME, umask, and group membership. Docker access needs the docker group.
Different tool versions.sh 'node -v; python3 -V; docker --version'.
Groovy errors
Error
Meaning
No such DSL method 'x'
Missing plugin, or Scripted syntax in a Declarative block
NotSerializableException
Non-serialisable object across a step boundary (Chapter 4)
Scripts not permitted to use …
Sandbox rejection — approve or use the supported step (Chapter 7)
expected to call X but wound up calling Y
CPS transformation confusion; usually a closure needing @NonCPS
MissingPropertyException: No such property: env
Using env outside a pipeline context
The controller is slow
/threadDump — look for many threads in the same place.
Load Statistics — is the queue deep?
Heap pressure — check vm_memory_heap_usage or the log for GC pauses.
Too many builds retained — thousands of build directories make job pages slow. Apply discarders.
Builds running on the controller — check that numExecutors is still 0.
A job vanished
Manage Jenkins → Job Config History if the plugin is installed. Otherwise restore jobs/<name>/config.xml from backup and Manage Jenkins → Reload Configuration from Disk.
Recovering a job config without a full restore
sudo -u jenkins cp /backup/extracted/jobs/api-build/config.xml \
/var/lib/jenkins/jobs/api-build/config.xml
# then: Manage Jenkins → Reload Configuration from Disk
The general method
Read the actual error, above the exit code 1.
Find which stage and step — Stage View, then Pipeline Steps.
Change one variable — different agent, clean workspace, previous commit.
Reproduce smaller — a pipeline with just the failing step, via Replay.
Turn on the right logger — scoped, FINE, then turn it off.
Takeaway: the queue tooltip, the agent launch log, and a scoped FINE logger answer most questions. Add a timeout to every pipeline so hangs become failures.
The important property: the same image tag flows through every environment. Production runs the exact bytes that passed the tests. Rebuilding per environment means production runs something never tested — a subtle and genuinely dangerous pattern.
Tag by commit SHA rather than build number. Build numbers are per-job and meaningless outside Jenkins; a SHA identifies the source exactly and survives a Jenkins rebuild.
Separating deploy into its own job
Often the right structure — deploy has different permissions and a different audience:
The deploy job can then be triggered independently for a rollback, and permissioned so only release managers can run it against production.
Rollback
The cheapest rollback is a deploy of a previous tag:
pipeline {
agent { label 'deploy' }
parameters {
choice(name: 'ENVIRONMENT', choices: ['staging', 'prod'])
string(name: 'IMAGE_TAG', description: 'Commit SHA to roll back to')
}
stages {
stage('Rollback') {
steps {
sh 'make deploy ENV=$ENVIRONMENT IMAGE=$IMAGE:$IMAGE_TAG'
}
}
}
}
Having this job already exist, tested, is worth far more than any amount of deployment sophistication. The worst time to write a rollback procedure is during an incident.
Deployment strategies
Rolling — replace instances gradually. The Kubernetes default.
Blue/green — deploy alongside, switch traffic, keep the old one to switch back. Rollback is a load-balancer change.
Canary — small share of traffic first, watch metrics, proceed or abort.
Jenkins orchestrates all three; the mechanics belong in your deployment tool, not the Jenkinsfile. Keep the pipeline thin — make deploy calling a script — so the logic is testable outside Jenkins.
Verify after deploying
stage('Smoke test') {
steps {
retry(3) {
sleep 10
sh 'curl -fsS https://api.example.com/health | jq -e ".status == \\"ok\\""'
}
}
post {
failure {
sh 'make rollback ENV=prod'
error 'Smoke test failed; rolled back'
}
}
}
A deploy that does not verify is a deploy that fails silently.
Takeaway: build once and promote the same artefact by commit SHA, keep deployment logic in scripts rather than the Jenkinsfile, and have a tested rollback job before you need it.
Benefits beyond capacity: every build gets a clean environment, so agent drift stops existing as a category of problem.
High availability
Open-source Jenkins has no HA. A controller is a single point of failure. Options:
Fast recovery — controller as code (JCasC) plus JENKINS_HOME on a replicated volume. Restart elsewhere in minutes. This is what most people do.
Active/passive — shared storage and a floating address. Fiddly; split-brain corrupts JENKINS_HOME.
CloudBees CI — the commercial product, which does offer HA and operations-centre features.
Be honest about the requirement. A build system being down for thirty minutes is usually an inconvenience rather than an outage, and engineering real HA is expensive. Fast, tested recovery is the better investment for almost everyone.
When to consider leaving
Worth naming plainly. Consider alternatives when:
Your builds are all container-native and you use none of Jenkins' flexibility.
Nobody wants to own the server — the most common reason, and a legitimate one.
You are on GitHub or GitLab and their native CI covers your needs.
Jenkins earns its keep when you need control over the machines, unusual platforms, or genuinely complex orchestration. If you need none of those, the maintenance cost is real and worth weighing.
Takeaway: tune vertically first — heap, discarders, fast disk — then make agents ephemeral. Splitting controllers is a real cost, and open-source Jenkins has no HA, so invest in fast tested recovery instead.
[ ] Jenkins URL correct, including scheme
[ ] Admin email set
[ ] Controller config in JCasC, in git
[ ] Job config in Jenkinsfiles, in git
[ ] Shared library pinned to a tag, not main
[ ] Agents provisioned by config management or immutable images
Pipelines
[ ] Jenkinsfile in every repository
[ ] Multibranch rather than per-branch jobs
[ ] Webhooks configured; polling only as fallback
[ ] cron uses H in the minute field
[ ] Secrets via withCredentials, single-quoted shell
[ ] Test results published in post { always }
[ ] Quality gates measure the delta, not the total
[ ] Build once, promote the same artefact
[ ] A tested rollback job exists
The five that matter most
If you only do five things:
Zero executors on the controller. Removes the largest privilege-escalation path.
Tested backups. The difference between an incident and a catastrophe.
Patch plugins. The main attack surface.
Jenkinsfiles and JCasC in git. Review, history, and reproducibility all at once.
Retention policies everywhere. Prevents the most common outage.
Everything else is refinement.
Takeaway: run this checklist quarterly. Anything you cannot answer confidently is where your next incident comes from.