Before GitHub Actions existed, Jenkins was CI. It still is, for a huge proportion of the industry. Banks, insurance companies, healthcare systems, government agencies, and large enterprises run Jenkins because they need their CI server on-premise, behind a firewall, or tightly integrated with internal tooling that GitHub Actions cannot reach. If you work in enterprise software — especially Java environments — understanding Jenkins is not optional.
What Jenkins is
Jenkins is an open-source automation server. You install it on a machine you control (or a VM, or a container), configure it, and it runs whatever you tell it to. Unlike GitHub Actions, there is no managed runner fleet — you provision the machines, install the tools, and maintain everything. That's more work to set up, but it's also total control: your build runs on your hardware, with access to your internal network, your private registries, your on-premise databases.
The trade-off is real. Small teams usually prefer GitHub Actions or GitLab CI because the infrastructure is managed for you. Large enterprises often prefer Jenkins because the plugin ecosystem is enormous, the configuration flexibility is unmatched, and the security model keeps code and credentials entirely in-house.
Two syntaxes: Declarative and Scripted
Jenkins has two ways to write pipelines, both stored in a file called Jenkinsfile at the root of your repository.
Declarative Pipeline is structured and opinionated. It follows a fixed schema — pipeline, agent, stages, post — and Jenkins validates the structure before running. It's easier to read, easier to review in pull requests, and the recommended choice for QA engineers who don't have a background in Groovy.
Scripted Pipeline is free-form Groovy. You write arbitrary code inside a node { } block. It's more powerful when you need complex logic — loops, conditionals, dynamic stage generation — but it's also harder to audit and easier to break in subtle ways.
Start with Declarative. Move to Scripted only when Declarative can't express what you need.
A complete Declarative Jenkinsfile
pipeline {
agent any // run on any available Jenkins agent
tools {
maven 'Maven-3.9' // name configured in Jenkins → Tools
jdk 'JDK-21'
}
stages {
stage('Checkout') {
steps {
checkout scm // clone the repo from the configured SCM
}
}
stage('Build') {
steps {
sh 'mvn clean compile -B'
}
}
stage('Unit Tests') {
steps {
sh 'mvn test -Dgroups=unit -B'
}
}
stage('Smoke Tests') {
steps {
sh 'mvn test -Dgroups=smoke -Dheadless=true -B'
}
}
}
post {
always {
junit 'target/surefire-reports/*.xml' // parse test results
}
success {
echo 'Pipeline passed.'
}
failure {
echo 'Pipeline failed. Check test reports.'
}
}
}The structure, piece by piece
pipeline { } — the top-level wrapper. Everything lives inside this block for Declarative pipelines.
agent — where to run. agent any picks any available agent. agent none defers the agent choice to individual stages (useful when different stages need different environments). agent { label 'linux-docker' } targets a specific agent pool. agent { docker 'maven:3.9-eclipse-temurin-21' } runs the stage inside a Docker container.
tools — pre-configured software. Add Maven, JDK, and Node.js installations in Jenkins → Manage Jenkins → Tools. Reference them by the name you gave them there. Jenkins adds the tool's bin directory to PATH automatically.
stages and stage — your pipeline's phases. Each stage has a name that appears in the Jenkins build visualisation. Put one meaningful chunk of work in each stage: Checkout, Build, Unit Tests, Smoke, Deploy. Finer-grained stages give you better failure visibility.
steps — the actual commands inside a stage. sh 'command' runs a shell command on Linux/macOS agents. bat 'command' runs a batch command on Windows. echo 'message' prints to the log. checkout scm clones from the source control manager the job is configured against.
post — runs after all stages complete, regardless of outcome. always { } runs no matter what. success { } runs only on a green build. failure { } runs only on a failed build. unstable { } runs when the build is marked unstable (test failures without a script failure). The junit step in post { always { } } is where you publish test results — it must run even when tests fail, which is why it belongs in always.
The Jenkinsfile lives in your repo
Like GitHub Actions workflows, a Jenkinsfile is code — version controlled, reviewed in pull requests, and rolled back if it breaks. In Jenkins, create a Pipeline job and point it to your repository. Jenkins fetches the Jenkinsfile from the repo root on every build. Changes to the pipeline are a pull request like any other change.
This is "pipeline as code" — the same principle as the .github/workflows/ directory in GitHub Actions. The benefit is the same: your CI configuration doesn't drift silently from the code it builds.
- – any / none
- – label 'linux'
- – docker image
- – maven 'Maven-3.9'
- – jdk 'JDK-21'
- – nodejs 'Node-20'
- – Checkout
- – Build
- – Test
- – Deploy
- always { junit } –
- success { notify } –
- failure { alert } –
Scripted Pipeline — when you need it
node('linux') { // run on an agent with the 'linux' label
stage('Checkout') {
checkout scm
}
stage('Build and test') {
try {
sh 'mvn clean test -B'
} finally {
junit 'target/surefire-reports/*.xml' // publish even if tests fail
}
}
if (env.BRANCH_NAME == 'main') {
stage('Deploy') {
sh './deploy.sh staging'
}
}
}The try/finally pattern is how Scripted pipelines guarantee cleanup — the Declarative post { always { } } block handles this more cleanly. Use Scripted only when you need dynamic behaviour Declarative can't express, like generating stages from a list at runtime.
⚠️ Common mistakes
- Skipping the
post { always { junit ... } }block. If you publish test results inside astage, the step is skipped when the stage fails — so you get no test report for your most important case. Always publish inpost { always { } }. - Using
agent anyin production pipelines without labels.agent anyruns on the first available machine, which may have a different JDK, Maven version, or OS than expected. Use agent labels to target the right environment. - Not version-controlling the Jenkinsfile. Teams that manage Jenkinsfiles through the Jenkins UI (rather than in the repo) lose the history, the review process, and the ability to roll back. Put every Jenkinsfile in the repo it builds.
🎯 Practice task
Read and annotate a real Jenkinsfile — 20 minutes.
- Search GitHub for open-source Java projects with a
Jenkinsfileat the root. Good examples:spring-projects/spring-framework, or any enterprise Java open-source project. - Open the Jenkinsfile and identify each structural block:
pipeline,agent,tools,stages,post. - Find where the test command is. What framework does it use? What system properties are passed?
- Find the
postblock. What runs on success? On failure? Does it publish test results? - Write your own. Create a
Jenkinsfilefor your Selenium or Playwright project. Start with the complete example above, replace the test command with your project's command, and add apost { always { } }block that publishes your test report format. You don't need a running Jenkins to write the file — just commit it to your repo and read it back.
The next lesson adds parameterised builds, conditional stages, and parallel execution — turning a basic pipeline into a real QA automation engine.