Jenkins Declarative Pipeline Syntax: 10 Gotchas That Will Burn You in Production
Master Jenkins Declarative Pipeline with real incident stories, debug guides, and production patterns.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Jenkins 2.x, Git, Docker, Java 11+, Basic Groovy syntax, Jenkins Blue Ocean plugin (optional)
- Use
agent noneat top and per-stage agents to avoid resource waste. - Always set
options { timestamps() }andtimeout(time: 30, unit: 'MINUTES'). - Wrap sensitive env vars with
credentials()and never hardcode secrets. - Use
when { expression { env.BRANCH_NAME == 'main' } }to gate stages. - Stash/unstash for cross-stage artifacts but limit size (<500MB).
- Matrix directive for parallel OS/JDK combos, not manual parallel blocks.
- Script block is a code smell—use declarative alternatives first.
- Post conditions
always,success,failurefor cleanup and notifications.
Imagine a factory assembly line: Declarative Pipeline is the blueprint that tells robots (agents) what to do at each station (stage). The agent directive assigns which robot works on a car, stages define the assembly steps, and post handles what happens after each step (e.g., send an alert if a weld fails). Think of when as a gatekeeper that only lets certain cars proceed if they meet conditions (like color 'blue'). environment is like a shared clipboard with instructions for all robots. parameters let the factory manager choose options before starting the line. matrix is like having multiple parallel assembly lines for different car models simultaneously. credentials are like locked toolboxes that only authorized robots can open. script is the emergency manual override when the blueprint doesn't cover a special case—use sparingly or you lose the benefits of the declarative model.
I remember the first time I deployed a Jenkins Pipeline to production—it was a Friday, 4:55 PM. The pipeline ran fine on my laptop, but on the production Jenkins master, it stalled for 45 minutes. Turns out, I forgot options { and the log was a jumbled mess. Worse, a timestamps() }when condition evaluated env.BRANCH_NAME as null because I hadn't set agent any at the top. That night, I learned that Declarative Pipeline is not just syntax—it's a discipline. Over the years, I've seen teams burn hours on stash size limits, matrix axis misconfigurations, and credentials leaking into logs. This article is the guide I wish I had back then: a production-tested reference for Jenkins Declarative Pipeline with every gotcha I've encountered.
1. Agent Directive: The Foundation of Your Pipeline
The agent directive tells Jenkins where to execute your pipeline or stage. In production, the most common mistake is using agent any at the top level, which randomly assigns any available agent. This can cause environment inconsistencies (e.g., different OS, missing tools). Best practice: use agent none at the pipeline level and define specific agents per stage. For example, agent { label 'linux && docker' } for a build stage and agent { label 'windows' } for a packaging stage. You can also use agent { docker 'maven:3.8.1' } to run inside a container—this ensures reproducibility. However, beware of Docker agent overhead: pulling images adds minutes. Use agent { docker { image 'maven:3.8.1' args '-v /tmp:/tmp' } } to mount volumes. Also, remember that agent can be overridden at stage level, but the stage agent inherits the pipeline agent's workspace unless you use reuseNode true. In production, I've seen pipelines fail because a stage agent tried to access files from a different node. Always stash/unstash or use shared volumes.
agent { label 'my-label' } with explicit labels defined on your nodes. This avoids silent failures from incompatible agents.agent any picked a node without Docker. After switching to agent { label 'docker' }, failures dropped to zero. Also, use agent { docker { image '...' args '--entrypoint=''' } } to avoid default entrypoints that conflict with Jenkins.2. Stages and Steps: Structuring Your Workflow
The stages block contains one or more stage directives, each with a name and steps. Each stage runs sequentially by default. Inside steps, you use declarative steps like sh, echo, git, etc. Avoid using script blocks unless necessary—they break the declarative model and disable some features like post conditions. Use parallel within a stage for concurrency. For complex workflows, use matrix (see section 11). A common mistake is putting too many steps in one stage—split into logical stages (Build, Test, Deploy) for better visibility and error handling. Each stage can have its own agent, tools, environment, when, and post. Use stage('Name') { ... } with clear names. In production, we use stages like 'Checkout', 'Compile', 'Unit Tests', 'Integration Tests', 'Package', 'Deploy to Staging', 'Smoke Tests', 'Deploy to Prod'. This granularity helps pinpoint failures quickly.
when to skip stages for certain branches (e.g., skip deploy on PRs).3. Post Conditions: Handling Success, Failure, and Always
The post block defines actions to run after a stage or pipeline completes. It supports conditions: always, success, failure, unstable, changed, fixed, regression, aborted, cleanup. Use post for cleanup (e.g., archive artifacts, send notifications, clean workspace). A common pitfall: post runs even if the stage fails, but if the agent is gone (e.g., Docker container removed), post may not execute. Use always for critical cleanup. Also, post in a stage runs after that stage; pipeline-level post runs after all stages. In production, we use post { always { cleanWs() } } to clean workspace, but be careful: cleanWs() deletes the entire workspace, which may affect parallel stages. Use post { failure { emailext ... } } to alert on failures. Note that post conditions can be combined with when for conditional notifications.
post { always { cleanWs() } } fixed it, but then we realized that cleanWs() runs even on success, slowing down pipelines. We switched to post { success { cleanWs() } } and kept artifacts via archiveArtifacts.post block for cleanup and notifications. Use always for critical tasks, but be mindful of performance.4. Environment Variables: Managing Configuration
The environment directive allows you to define variables accessible throughout the pipeline. You can set static values, dynamic values from sh commands, or use credentials() to inject secrets. Variables declared at pipeline level are available in all stages unless overridden. Stage-level environment variables override pipeline-level ones for that stage only. Use env.VARNAME to access them in steps. A common mistake is to use environment { VAR = sh(...) } which sets VAR to the return code, not the output. Use script { env.VAR = sh(returnStdout: true, script: '...').trim() } for dynamic values. Also, beware of variable expansion: echo "$VAR" works, but sh 'echo $VAR' may not expand if the shell doesn't have the variable. Use sh "echo $VAR" with double quotes. In production, we use environment variables for build numbers, commit hashes, and artifact paths.
environment { DOCKER_TAG = sh(returnStdout: true, script: 'git rev-parse HEAD').trim() } was set at pipeline level, but a later stage used sh 'docker build -t app:$DOCKER_TAG .' and the variable was empty because the shell didn't inherit it. We fixed it by using withEnv inside the stage.script blocks for dynamic assignment and ensure shell steps use double quotes for variable expansion.5. Tools Directive: Maven, JDK, and More
The tools directive automatically configures tools like Maven, JDK, Gradle, etc. You must have these tools configured in Jenkins Global Tool Configuration. Use tools { maven 'Maven 3.8' } to set the PATH to include Maven. You can specify multiple tools: tools { maven 'Maven 3.8'; jdk 'JDK 11' }. Tools can be set at pipeline or stage level. A common gotcha: tool names are case-sensitive and must match exactly. Also, tools are only available on agents that have the tool installed or configured. If using Docker agents, tools may not be available unless the image has them. In production, we use Docker agents with pre-installed tools to avoid inconsistencies. Use tool name: 'Maven 3.8', type: 'maven' if you need more control. Note that tools only sets environment variables like JAVA_HOME and PATH; it doesn't install tools dynamically.
image 'maven:3.8-jdk-11' and skip the tools directive altogether.tools when agents don't have pre-installed software. Prefer Docker images with tools baked in for consistency.6. Triggers: Cron, Webhook, and Polling
The triggers directive defines how the pipeline is automatically triggered. Common triggers: cron (e.g., cron('0 2 *') for nightly builds), pollSCM (periodically check SCM for changes), upstream (triggered by other pipelines), and webhook (via Generic Webhook Trigger plugin). In declarative, you can only use cron, pollSCM, and upstream natively. For webhooks, you need the Generic Webhook Trigger plugin and use properties with pipelineTriggers. A common mistake: using cron without pollSCM leads to builds at fixed times regardless of changes. Use pollSCM for change-driven builds. Also, triggers only applies to the main branch by default; for PRs, use Multibranch Pipeline which automatically triggers on SCM changes. In production, we use cron for nightly integration tests and pollSCM for feature branches.
cron('H /4 ') thinking it would run every 4 hours, but it ran at random minutes because of the 'H' hash. Use explicit minutes for predictable schedules. Also, we accidentally triggered 20 builds in parallel because the webhook fired multiple times—use triggeredBy in Generic Webhook Trigger to deduplicate.pollSCM for code changes, cron for scheduled tasks. Avoid 'H' in cron if you need exact times.7. Parameters: Making Pipelines Interactive
The parameters directive allows users to provide input when triggering a build. Supported types: string, text, booleanParam, choice, password, file. Define parameters at pipeline level, then access them via params.PARAM_NAME. A common mistake: using parameters in a Multibranch Pipeline—they are not supported; use properties with buildDiscarder and parameters instead. Also, parameters are evaluated at build time, so when conditions can use params. In production, we use choice for deployment environments (dev, staging, prod) and booleanParam for skipping tests. Be careful with password parameters—they are masked but can be exposed if echoed. Use for sensitive data instead.credentials()
script block and using params.VERSION only in safe contexts.password parameters; use credentials binding instead.8. Options: Timestamps, Timeout, Retry, and Build Discarder
The options directive configures pipeline behavior. Essential options: (adds timestamps to console output), timestamps()timeout(time: 1, unit: 'HOURS') (overall pipeline timeout), retry(3) (retry the entire pipeline on failure), buildDiscarder(logRotator(numToKeepStr: '10')) (keep only 10 builds). Other useful options: skipDefaultCheckout(), disableConcurrentBuilds(), preserveStashes(), parallelsAlwaysFailFast(). Options can be set at pipeline or stage level. A common mistake: forgetting makes debugging impossible. Also, timestamps()retry retries the whole pipeline, not individual stages—use retry inside a stage step for stage-level retries. In production, we always set , timestamps()timeout, and buildDiscarder. For long-running pipelines, we set timeout per stage as well.
options { timeout(time: 30, unit: 'MINUTES') } prevented that. Also, buildDiscarder saved us from filling disk space with old builds.timestamps(), a pipeline-level timeout, and buildDiscarder in every pipeline.9. When Conditions: Branch, Expression, BuildingTag, and AnyOf
The when directive controls whether a stage executes based on conditions. Common conditions: branch 'main', expression { return env.BRANCH_NAME == 'main' }, buildingTag(), tag "v*", changeRequest(), environment name: 'VAR', value: 'val'. You can combine conditions with anyOf or allOf. A common mistake: using branch in a Multibranch Pipeline—it works, but for PRs, branch matches the source branch. Use changeRequest() for PR-specific logic. Also, when is evaluated before the stage's agent is allocated unless you set beforeAgent true. In production, we use when { branch 'main' } for deployment stages and when { buildingTag() } for release stages. Be careful with expression—it can throw exceptions if the variable is null.
when { tag } without buildingTag(). The correct syntax is when { buildingTag() }. Also, using expression { env.TAG_NAME ==~ /v.*/ } is more flexible.when to skip stages based on branch, tag, or environment. Always test conditions with beforeAgent true if the stage's agent is expensive.10. Credentials: UsernamePassword, SSH Key, and String
The helper in credentials()environment injects credentials as environment variables. Supported types: usernamePassword, sshUserPrivateKey, string, file. Use credentials('my-cred') which creates MY_CRED_USR and MY_CRED_PSW for usernamePassword. For SSH keys, it creates MY_CRED containing the private key. A common mistake: using inside a credentials()script block—it only works in environment. For dynamic credential usage, use withCredentials step. Also, never echo credentials—Jenkins masks them only if used correctly. In production, we use withCredentials([sshUserPrivateKey(credentialsId: 'ssh-key', keyFileVariable: 'SSH_KEY')]) for SSH operations. Always rotate credentials periodically.
environment { PASS = credentials('db-pass') } and then sh "mysql -u root -p$PASS" which exposed the password in the process list. We switched to using withCredentials and passing via file.withCredentials for credential usage inside steps. Never assign credentials to environment variables that will be used in shell commands.11. Matrix: Parallel Builds with Multiple Axes
The matrix directive allows you to run the same stage across multiple axis combinations (e.g., different OS, JDK versions). It's a declarative way to achieve parallelism without manual parallel blocks. Define axes like axis { name 'OS'; values 'linux', 'windows' } and axis { name 'JDK'; values '11', '17' }. Use excludes to skip invalid combinations. Each combination runs in parallel with its own agent (if specified). A common mistake: forgetting that matrix stages share the same workspace? Actually, each combination gets its own workspace. Also, matrix can be nested inside a stage. In production, we use matrix for cross-platform testing. Be careful with resource usage—too many combinations can overwhelm your Jenkins cluster. Use failFast true to stop all on first failure.
excludes to remove unsupported combos.12. Script Block: The Groovy Escape Hatch (Use Sparingly)
The script block allows you to use arbitrary Groovy code inside a declarative pipeline. It's useful for complex logic that can't be expressed declaratively, like loops, conditionals, or dynamic variable assignment. However, overusing script defeats the purpose of declarative pipeline—you lose validation, error handling, and Blue Ocean visualization. Best practices: limit script blocks to a few lines, avoid side effects, and prefer declarative alternatives (e.g., when for conditions, matrix for parallelism). Common use cases: parsing JSON, generating dynamic parameters, or calling APIs. In production, we use script to read a file and set environment variables dynamically. Always wrap script in a stage and handle exceptions with try-catch.
script to loop over a list of services and deploy them sequentially. It worked, but when one service failed, the loop continued, causing partial deployments. We replaced it with a parallel block inside a script block to fail fast.script only when necessary. Keep it short and handle errors explicitly. Prefer declarative constructs over Groovy code.The Silent Credential Leak: How a Pipeline Exposed SSH Keys in Logs
sshUserPrivateKey are automatically masked in logs.withCredentials but then assigned the key to an environment variable and printed it with echo "$SSH_KEY". Jenkins masks only the credential binding, not subsequent variable expansion.sh 'command' directly with the credential binding. Add options { ansiColor('xterm') } and options { timestamps() } for traceability but avoid printing secrets. In this case, we rotated the key, removed the echo, and added a post { failure { emailext ... } } to alert on log leaks.- Treat credentials as radioactive—never touch them directly.
- Use
withCredentialsblocks and avoid assigning to env vars unless absolutely necessary. - Enable Jenkins' Credential Masking plugin and review logs periodically.
java -jar jenkins-cli.jar -s http://jenkins:8080/ list-nodesPrint-friendly master reference covering all topics in this track.
| File | Command / Code | Purpose |
|---|---|---|
| jenkinsfile-declarative-pipeline_example.python | pipeline { | 1. Agent Directive |
| jenkinsfile-declarative-pipeline_example.python | stages { | 2. Stages and Steps |
| jenkinsfile-declarative-pipeline_example.python | post { | 3. Post Conditions |
| jenkinsfile-declarative-pipeline_example.python | environment { | 4. Environment Variables |
| jenkinsfile-declarative-pipeline_example.python | tools { | 5. Tools Directive |
| jenkinsfile-declarative-pipeline_example.python | triggers { | 6. Triggers |
| jenkinsfile-declarative-pipeline_example.python | parameters { | 7. Parameters |
| jenkinsfile-declarative-pipeline_example.python | options { | 8. Options |
| jenkinsfile-declarative-pipeline_example.python | when { | 9. When Conditions |
| jenkinsfile-declarative-pipeline_example.python | matrix { | 11. Matrix |
| jenkinsfile-declarative-pipeline_example.python | stage('Dynamic Steps') { | 12. Script Block |
Key takeaways
agent none at pipeline level and use per-stage agents.options { timestamps(); timeout(30, 'MINUTES'); buildDiscarder(...) } in every pipeline.when with beforeAgent true to skip expensive agent allocation.withCredentials for secure injection.matrix over manual parallel for cross-product testing.script blocks minimal; use declarative alternatives first.post for cleanup and notifications; always include always for critical tasks.Interview Questions on This Topic
What is the difference between `agent any` and `agent none`?
agent any allocates any available agent for the entire pipeline. agent none defers agent allocation to individual stages, allowing different stages to run on different agents. Use agent none for multi-platform pipelines.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Jenkins. Mark it forged?
6 min read · try the examples if you haven't