Jenkins has three ways to define what a build does. Only one of them can be reviewed alongside the code it builds, and that difference matters more than any feature comparison.
Topic 1: The Three Job Types
Freestyle — configured entirely in the web UI, stored as jobs/<name>/config.xml on the controller. No review, no diff, no branch awareness. Duplicating it means copying it and editing 40 form fields.
Pipeline — the build is defined by a Jenkinsfile, usually in the repository. Versioned with the code, reviewable in a pull request, and it can differ per branch because it is per branch.
Multibranch pipeline — Jenkins scans the repository, creates a job for every branch containing a Jenkinsfile, and removes jobs for branches that are deleted. Pull requests are built too.
The migration path is worth stating plainly: anything long-lived should be a multibranch pipeline. Freestyle jobs are acceptable for a one-off administrative task and are a liability for a service.
Topic 2: A Minimal Jenkinsfile
pipeline {
agent { label 'linux' }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds(abortPrevious: true)
}
stages {
stage('Build') {
steps {
sh './gradlew --no-daemon assemble'
}
}
stage('Test') {
steps {
sh './gradlew --no-daemon test'
}
post {
always {
junit 'build/test-results/test/*.xml'
}
}
}
}
post {
failure {
slackSend channel: '#builds', message: "FAILED ${env.JOB_NAME} #${env.BUILD_NUMBER}"
}
}
}
Three details in there that are easy to skip and worth having from the start:
timeout— without it, a hung build holds an executor forever. Every pipeline needs one.disableConcurrentBuilds(abortPrevious: true)— on a busy branch, only the newest commit matters; this stops five builds of superseded commits from queueing.junitinpost { always }— test results are published even when the build fails, which is exactly when you want them.
Topic 3: Declarative vs Scripted
Both are Groovy underneath. The difference is how much structure Jenkins imposes.
// Declarative — a defined structure Jenkins validates before running
pipeline {
agent any
stages {
stage('Build') { steps { sh 'make' } }
}
}
// Scripted — a Groovy program. No structure, no validation, full power.
node('linux') {
stage('Build') {
checkout scm
sh 'make'
}
}
| Declarative | Scripted | |
|---|---|---|
| Validated before running | Yes | No — errors appear mid-build |
| Restart from a stage | Yes | No |
| Readable without Groovy | Mostly | No |
| Arbitrary logic | Via script { } blocks | Everywhere |
| Blue Ocean / stage view | Full support | Partial |
Use declarative. When you genuinely need imperative logic, put it in a script { } block, or better, in a shared library step so the Jenkinsfile stays readable:
stage('Deploy') {
steps {
script {
def targets = readYaml(file: 'deploy-targets.yaml')
targets.each { t -> deployTo(t) }
}
}
}
A Jenkinsfile that is mostly script { } has become a scripted pipeline with extra ceremony — at that point, either simplify it or move the logic into a library.
Topic 4: Multibranch, and What It Automates
A multibranch pipeline is configured once against a repository. Jenkins then:
- Scans for branches containing a
Jenkinsfileand creates a job for each. - Builds pull requests, either the PR head or the merge result — the merge result is what you want, since that is what will land.
- Deletes jobs when branches are deleted, so the job list matches reality.
- Exposes branch context to the pipeline:
env.BRANCH_NAME,env.CHANGE_ID(set only for pull requests),env.CHANGE_TARGET.
Which lets one Jenkinsfile behave correctly everywhere:
stage('Deploy to staging') {
when {
branch 'main'
// and not a PR
not { changeRequest() }
}
steps { sh './deploy.sh staging' }
}
Configure the scan trigger properly. Polling every minute across 200 repositories is a common cause of a slow controller. Use a webhook from the forge, and keep periodic scanning as a slow fallback (every few hours) for missed events.
Orphaned item strategy decides how long a deleted branch’s job and history survive. Keeping a handful is useful for post-mortems; keeping all of them forever is how jobs/ grows without bound.
Topic 5: Pull Request Builds and the Trust Boundary
A pull request from a fork contains code you have not reviewed, and building it means executing that code — including whatever the contributor put in the Jenkinsfile.
The rules that keep this safe:
- Fork PRs must not have credentials. Multibranch has a trust setting per organisation folder — build fork PRs with no credentials, or only after a collaborator approves. The default of “trust everyone” is a shell on your build infrastructure.
- Do not deploy from a PR build. Ever. PR builds compile, test and scan; they do not touch an environment.
- Beware the
Jenkinsfileitself. A fork PR can change the pipeline. Building the merge result with the base branch’s Jenkinsfile is the safer configuration where your forge and plugins support it.
This is the same trust boundary that GitHub Actions expresses through pull_request versus pull_request_target, and it has produced real compromises in both ecosystems.
Topic 6: Migrating Off Freestyle Without a Big Bang
A workable order for an existing estate:
1. Inventory. jenkins-cli list-jobs, or read jobs/*/config.xml.
Group by: still used, used rarely, dead.
2. Delete the dead ones. This is usually a third of them.
3. For each survivor, write a Jenkinsfile that reproduces it — starting
with the noisiest, most-changed job, because that is where the pain is.
4. Run both in parallel for a week; compare outcomes.
5. Switch the trigger to the multibranch job, disable the freestyle one.
6. Delete the freestyle job a fortnight later, once nobody has missed it.
Two things that make this easier: the Job DSL or Jobs-as-Code approach for creating jobs from a seed, and the jenkins-cli declarative-linter for validating a Jenkinsfile before committing it:
ssh -p 50022 jenkins declarative-linter < Jenkinsfile
# or over HTTP with a crumb:
curl -X POST -F "jenkinsfile=<Jenkinsfile" https://jenkins.example/pipeline-model-converter/validate
Wiring that linter into a pre-commit hook or a CI check removes a whole category of “the build failed because the Jenkinsfile had a typo”.
Try it yourself: create a branch, change one stage name in its Jenkinsfile, and push. A job appears for the branch, runs with the changed pipeline, and main is untouched. That is the property freestyle jobs cannot express at all.
Common mistake: keeping the pipeline in a separate “pipelines” repository so the platform team can control it. It sounds tidy and it breaks the main benefit — a code change and its build change can no longer be reviewed together, and every pipeline change becomes a cross-team ticket. Put the Jenkinsfile in the repository it builds, and use a shared library for the parts that must be centrally controlled.