Freestyle vs Pipeline jobs
Why pipeline-as-code wins.
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
# 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
pipeline {agent { label 'linux' } // run on a labelled agent, never the controlleroptions { 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 JUnitResultArchiversuccess { 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.
Branch indexingConnecting to https://github.com/acme/web.gitObtained Jenkinsfile from 7c1e9ab[Pipeline] Start of Pipeline[Pipeline] nodeRunning 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] junitRecording test results[Pipeline] archiveArtifactsArchiving artifacts[Pipeline] End of PipelineFinished: 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.
$ 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
[Fri Jul 11 09:14:02 UTC 2025] Starting branch indexing...> git rev-parse --resolve-git-dir /var/jenkins_home/caches/git-3f2a/.gitSetting origin to https://github.com/acme/web.git> git config remote.origin.url https://github.com/acme/web.gitFetching & pruning origin...> git fetch --tags --force --progress --prune -- origin +refs/heads/*:refs/remotes/origin/*Checking branches...Checking branch main'Jenkinsfile' foundMet criteriaNo changes detected in main (still at 3f9a1c2e)Checking branch feature/login'Jenkinsfile' foundMet criteriaScheduled build for branch: feature/loginChecking branch docs/readme-typo'Jenkinsfile' not foundDoes not meet criteria3 branches were processed[Fri Jul 11 09:14:05 UTC 2025] Finished branch indexing. Indexing took 3.2 secFinished: 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.
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.
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?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.