The declarative Jenkinsfile
pipeline, agent, stages, steps.
A recipe card keeps its sections in a fixed order. Ingredients, then method, then oven temperature. Anyone can pick it up and cook from it. The other kind of recipe is the one scrawled on the back of an envelope, where the flour turns up halfway through a story about somebody's holiday. Both get dinner made; only one survives being handed around a kitchen. Jenkins offers you exactly those two choices. A Jenkinsfile is a plain text file you commit next to your code, and it tells Jenkins how to build and test that code. Jenkins reads it in one of two dialects. Scripted pipeline is raw Groovy (a full programming language that runs on the Java platform), which is powerful and very easy to grow into a tangle no reviewer can follow. Declarative pipeline is the recipe card: a small set of named blocks that have to nest in a set order. Write declarative. It reads top to bottom, and Jenkins checks the shape of the file before it runs a single command, so a mistake in the structure fails immediately instead of halfway through a deploy.
pipeline {agent { label 'linux' } // where the whole pipeline runsoptions { timeout(time: 20, unit: 'MINUTES') } // kill a hung buildenvironment { APP = 'payments-api' } // non-secret config for every stagestages {stage('Build') {steps { sh 'npm ci && npm run build' }}stage('Test') {steps { sh 'npm test' } // writes reports/junit.xml}}post {always { junit 'reports/*.xml' } // publish tests, pass or failsuccess { echo "OK ${APP} #${env.BUILD_NUMBER}" }failure { echo 'build failed — read the console output above' }}}
Every declarative Jenkinsfile opens with pipeline { }, the outer wrapper that holds everything else. Inside it, three blocks carry the weight. agent says where the work runs, meaning which machine Jenkins hands the job to; that machine is called an agent. stages holds one or more stage blocks, and a stage is a named phase of the job (Build, Test, Deploy) that Jenkins draws as a labelled box in its web interface, so you can see at a glance how far a build got and where it fell over. Inside each stage, steps lists the actual commands, and sh (short for shell, meaning run this in a terminal on the agent) is the one you will type most. Three optional blocks sit around that core. options tunes behaviour, and here it sets a 20 minute timeout so a hung build cannot spin forever. environment defines plain, non-secret variables every stage can read. post runs once the stages have finished.
That order is enforced, not suggested. agent comes before stages. Every command lives inside a steps block. Dropping a bare sh straight under stage is the mistake almost everyone makes in their first week, and Jenkins refuses the file the moment it parses it, before anything has run. You find out in two seconds rather than two minutes into a deploy. That is the payoff of a fixed skeleton.
Watch it run: reading the Console Output
post is the block beginners leave out and later wish they had not. It runs after the stages are done, and the blocks inside it are keyed by how the build ended: always, success, failure, unstable. That is what gets your test report published and your alert sent whether the build went green or red. Trigger the pipeline and Jenkins streams a Console Output at you, a line by line log of every step, with [Pipeline] markers naming each block as it opens. Learning to read that log from the top down is most of what debugging a pipeline actually is.
Started by user admin[Pipeline] Start of Pipeline[Pipeline] nodeRunning on linux-agent-2 in /home/jenkins/workspace/payments-api[Pipeline] {[Pipeline] withEnv[Pipeline] {[Pipeline] stage[Pipeline] { (Build)[Pipeline] sh+ npm ciadded 214 packages in 6s+ npm run build> tsc -p .[Pipeline] }[Pipeline] // stage[Pipeline] stage[Pipeline] { (Test)[Pipeline] sh+ npm testTests: 48 passed, 48 total[Pipeline] }[Pipeline] // stage[Pipeline] stage[Pipeline] { (Declarative: Post Actions)[Pipeline] junitRecording test results[Pipeline] echoOK payments-api #37[Pipeline] }[Pipeline] // stage[Pipeline] }[Pipeline] // withEnv[Pipeline] }[Pipeline] // node[Pipeline] End of PipelineFinished: SUCCESS
A failure is hard to miss in that log. The sh line that broke returns a non-zero exit code (any number other than zero, which is how a command reports that it failed). Jenkins paints that stage red, skips whatever was left, and jumps straight to the matching block inside post. That is precisely why post { failure { } } exists: the report still publishes and the alert still goes out on the way down. Hide the test report every time a build breaks and your team learns to ignore CI (continuous integration, the habit of building and testing every push automatically) within a month. Wiring results and notifications into post keeps the signal honest in both directions.
Where it runs: agent any, none, or docker
agent answers one question. Where do these commands actually run? An agent is a machine or a container that Jenkins hands work to, and each one offers some number of executors, meaning single build slots, one job per slot. Choosing an agent is like choosing a kitchen. agent any takes the first free slot anywhere, the shared hostel kitchen where you have no idea what is in the cupboards. Fine for a demo, careless in production, because you control neither the tools installed nor who else is cooking. agent { label 'linux' } pins the pipeline to agents tagged linux, so a build that needs Docker or a deploy key lands on a machine that has one. agent none declares no global agent at all and forces every stage to name its own. And agent { docker { image 'node:22-alpine' } } runs your steps inside a throwaway container built from a pinned image, a kitchen delivered flat-packed with exactly the tools you asked for and thrown away when you are done. No slow drift from someone hand-installing Node on that agent two years ago.
pipeline {agent none // no global agent; each stage picks its ownstages {stage('Build') {agent { docker { image 'node:22-alpine' } } // throwaway pinned containersteps { sh 'npm ci && npm run build' }}stage('Deploy') {agent { label 'deploy' } // a locked-down, dedicated workersteps { sh './deploy.sh production' }}}}
There is a security decision buried in that choice, not a preference. Your steps are arbitrary code, and they run with the agent's identity and the agent's access to your network. Pin the agent tightly (a labelled, hardened worker that is destroyed after each build, or a minimal container) and a dependency or a test that turns malicious has very little within reach. Whatever you pick, builds must never run on the controller, the Jenkins server itself, because it holds every credential you have ever stored. A clean default is agent none with a container per stage: build inside node:22-alpine, deploy on a locked-down worker, and no two stages sharing a workspace.
Because the skeleton never varies, Jenkins can check your file before it runs anything in it. The declarative linter, which you reach over the Jenkins CLI (command line interface, the small Java client that talks to your Jenkins server from a terminal), reads a Jenkinsfile and reports structural mistakes without starting a build. A step sitting directly under a stage, a block in the wrong place, that kind of thing. Wire it into a pre-commit hook, the script Git runs before it accepts a commit, or into a lint stage, and you catch shape errors at your desk instead of after a push.
# validate the pipeline's shape without starting a build$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth admin:$TOKEN \declarative-linter < JenkinsfileErrors encountered validating Jenkinsfile:WorkflowScript: 4: Expected one of "steps", "stages", "parallel", or "matrix" for stage "Test" @ line 4, column 5.stage('Test') { sh 'npm test' }^# fix: wrap the command in a steps { } block, then re-run$ java -jar jenkins-cli.jar -s http://localhost:8080/ -auth admin:$TOKEN \declarative-linter < JenkinsfileJenkinsfile successfully validated.
Declarative vs scripted, and when to open script { }
The gap between the two dialects is wider than style. A scripted pipeline is unrestricted Groovy from its first line, so Jenkins has no fixed shape to validate ahead of time, and a reviewer cannot skim it and know what it does. Arbitrary Groovy running on a build agent is a real security surface as well. It can read the filesystem, open network connections, and touch any credential the build can see. The fixed skeleton is the whole feature of declarative. A machine can check it, it diffs cleanly in a pull request, and it is hard to sneak something surprising past a reader. You give up a little raw power and get back a lot of reviewability.
Declarative handles almost everything a normal project needs, and it is deliberately fenced in. You cannot write loops or rich conditionals at the top level. When you genuinely need a scrap of real Groovy, you do not throw the file away and start over in scripted. You open a script { } block inside a step. That is the approved escape hatch, a fire door in an otherwise sealed room: the pipeline stays declarative and reviewable while the imperative logic sits fenced in one labelled corner. Use it sparingly, for things like computing a value at runtime or branching on what a command printed. If a script block grows past a few lines, move it into a shared library (covered in a later lesson) where it can be tested and reused instead of sprawling across your Jenkinsfile.
stage('Tag release') {steps {script {// real Groovy, fenced inside a declarative stepdef last = sh(script: 'git log --oneline -1', returnStdout: true).trim()if (last.contains('[release]')) {env.DO_DEPLOY = 'true'currentBuild.displayName = "release #${env.BUILD_NUMBER}"}}}}
agent none at the top, then hands the Build stage agent { docker { image 'node:22-alpine' } } and the Deploy stage agent { label 'deploy' }. What is agent none doing there?Try this
Run declarative-linter < Jenkinsfile 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 docker agent is only as isolated as the daemon behind it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.