DevOpsil
Jenkins
91%
Needs Review

Jenkins Declarative Pipelines: From Zero to Production CI/CD

Sarah ChenSarah Chen7 min read

Why Declarative Over Scripted

Jenkins supports two pipeline syntaxes. Scripted pipelines are pure Groovy — flexible but hard to read and validate. Declarative pipelines use a structured DSL that Jenkins can lint, visualize, and restart from specific stages. Unless you need advanced Groovy logic, declarative is the right default.

The fundamental structure:

pipeline {
  agent any

  stages {
    stage('Build') {
      steps {
        sh 'make build'
      }
    }
    stage('Test') {
      steps {
        sh 'make test'
      }
    }
    stage('Deploy') {
      steps {
        sh 'make deploy'
      }
    }
  }
}

Every declarative pipeline needs pipeline {}, at least one agent, and a stages {} block.


Agent Directives

The agent tells Jenkins where to run the pipeline. Options:

// Run on any available node
agent any

// Run in a Docker container
agent {
  docker {
    image 'node:20-alpine'
    args '-v /tmp:/tmp'
  }
}

// Run on a node with a specific label
agent {
  label 'linux && docker'
}

// No global agent — define per stage
agent none

Using agent none at the top level with per-stage agents is common when different stages need different environments:

pipeline {
  agent none

  stages {
    stage('Build') {
      agent { docker { image 'maven:3.9-eclipse-temurin-17' } }
      steps {
        sh 'mvn package -DskipTests'
      }
    }
    stage('Test') {
      agent { docker { image 'maven:3.9-eclipse-temurin-17' } }
      steps {
        sh 'mvn test'
      }
    }
    stage('Deploy') {
      agent { label 'production' }
      steps {
        sh './deploy.sh'
      }
    }
  }
}

Environment Variables and Credentials

Jenkins exposes many built-in variables (BUILD_NUMBER, BRANCH_NAME, GIT_COMMIT, etc.). Define your own with environment {}:

pipeline {
  agent any

  environment {
    APP_NAME    = 'my-service'
    REGISTRY    = 'registry.example.com'
    IMAGE_TAG   = "${APP_NAME}:${BUILD_NUMBER}"
  }

  stages {
    stage('Build Image') {
      steps {
        sh "docker build -t ${IMAGE_TAG} ."
        sh "docker push ${IMAGE_TAG}"
      }
    }
  }
}

For secrets, never hardcode — use credentials binding:

environment {
  // String credential — injects as env var
  API_KEY = credentials('my-api-key')

  // Username/password — injects as APP_CREDS_USR and APP_CREDS_PSW
  APP_CREDS = credentials('docker-hub-credentials')
}

Or bind inline inside a step:

steps {
  withCredentials([
    string(credentialsId: 'deploy-token', variable: 'DEPLOY_TOKEN'),
    usernamePassword(
      credentialsId: 'db-credentials',
      usernameVariable: 'DB_USER',
      passwordVariable: 'DB_PASS'
    )
  ]) {
    sh './deploy.sh'
  }
}

Parallel Stages

Run independent stages simultaneously to cut build times:

stage('Test') {
  parallel {
    stage('Unit Tests') {
      steps {
        sh 'npm run test:unit'
      }
    }
    stage('Integration Tests') {
      steps {
        sh 'npm run test:integration'
      }
    }
    stage('Lint') {
      steps {
        sh 'npm run lint'
      }
    }
  }
}

Each parallel branch runs on its own executor. If your agents are containerized, this is nearly free. If they share a node, watch for resource contention.


When Conditions

Skip stages based on conditions:

stage('Deploy to Production') {
  when {
    branch 'main'
    not { changeRequest() }
  }
  steps {
    sh './deploy-prod.sh'
  }
}

stage('Deploy to Staging') {
  when {
    anyOf {
      branch 'develop'
      branch 'staging'
    }
  }
  steps {
    sh './deploy-staging.sh'
  }
}

stage('Security Scan') {
  when {
    expression { return env.RUN_SECURITY_SCAN == 'true' }
  }
  steps {
    sh 'trivy image ${IMAGE_TAG}'
  }
}

Common when conditions:

  • branch 'main' — only on this branch
  • changeRequest() — only on pull requests
  • tag 'v*' — only on matching tags
  • environment name: 'DEPLOY_ENV', value: 'prod' — env var check
  • expression { <groovy> } — arbitrary logic

Post Conditions

Run steps after the pipeline finishes, regardless of outcome:

post {
  always {
    // Always runs — cleanup, notifications
    junit 'target/surefire-reports/**/*.xml'
    cleanWs()
  }
  success {
    slackSend(color: 'good', message: "Build ${BUILD_NUMBER} succeeded")
  }
  failure {
    slackSend(color: 'danger', message: "Build ${BUILD_NUMBER} FAILED")
    emailext(
      subject: "Build Failed: ${JOB_NAME} #${BUILD_NUMBER}",
      body: '${BUILD_LOG, maxLines=50}',
      to: '[email protected]'
    )
  }
  unstable {
    slackSend(color: 'warning', message: "Build ${BUILD_NUMBER} is unstable")
  }
}

post can appear at the pipeline level (runs after all stages) or inside a specific stage (runs after that stage).


Input Step: Manual Gates

Add a manual approval gate before production deployments:

stage('Approve Production Deploy') {
  when { branch 'main' }
  steps {
    input(
      message: 'Deploy to production?',
      ok: 'Deploy',
      submitter: 'devops-leads,release-manager',
      parameters: [
        string(name: 'RELEASE_NOTES', description: 'Notes for this release')
      ]
    )
  }
}

The build pauses and waits for a human to click "Deploy" in the Jenkins UI. submitter restricts who can approve. Set a timeout to avoid hanging forever:

timeout(time: 1, unit: 'HOURS') {
  input message: 'Deploy to production?'
}

Options Block

Configure pipeline-wide behavior:

pipeline {
  options {
    timeout(time: 30, unit: 'MINUTES')   // Fail if pipeline runs > 30 min
    buildDiscarder(logRotator(numToKeepStr: '10'))  // Keep last 10 builds
    disableConcurrentBuilds()             // Don't run multiple builds in parallel
    retry(2)                              // Retry the entire pipeline up to 2 times
    timestamps()                          // Prefix all output with timestamps
    skipStagesAfterUnstable()            // Don't run later stages if unstable
  }

  // ...
}

Shared Libraries

For large organizations with many pipelines, shared libraries extract common logic into a reusable package. Structure:

jenkins-shared-library/
  vars/
    buildAndPush.groovy    # Global variable / custom step
    deployToK8s.groovy
  src/
    org/example/
      Docker.groovy        # Groovy class

Define a global variable (custom step):

// vars/buildAndPush.groovy
def call(String imageName, String tag) {
  sh "docker build -t ${imageName}:${tag} ."
  sh "docker push ${imageName}:${tag}"
}

Use it in a Jenkinsfile after loading the library:

@Library('jenkins-shared-library@main') _

pipeline {
  agent any
  stages {
    stage('Build') {
      steps {
        buildAndPush('my-service', env.BUILD_NUMBER)
      }
    }
  }
}

Register the library in Jenkins: Manage Jenkins → System → Global Pipeline Libraries.


Complete Production Jenkinsfile

A realistic pipeline for a containerized Node.js service:

@Library('shared-lib@main') _

pipeline {
  agent none

  options {
    timeout(time: 20, unit: 'MINUTES')
    buildDiscarder(logRotator(numToKeepStr: '20'))
    disableConcurrentBuilds()
  }

  environment {
    REGISTRY    = 'registry.example.com'
    APP         = 'api-service'
    IMAGE       = "${REGISTRY}/${APP}"
    REGISTRY_CREDS = credentials('registry-credentials')
  }

  stages {
    stage('Checkout') {
      agent any
      steps {
        checkout scm
        script {
          env.GIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
          env.IMAGE_TAG = "${IMAGE}:${GIT_SHORT}"
        }
      }
    }

    stage('Test') {
      agent { docker { image 'node:20-alpine' } }
      parallel {
        stage('Unit') {
          steps { sh 'npm ci && npm test' }
        }
        stage('Lint') {
          steps { sh 'npm ci && npm run lint' }
        }
      }
    }

    stage('Build & Push') {
      agent any
      steps {
        sh "docker login -u ${REGISTRY_CREDS_USR} -p ${REGISTRY_CREDS_PSW} ${REGISTRY}"
        sh "docker build -t ${IMAGE_TAG} ."
        sh "docker push ${IMAGE_TAG}"
      }
    }

    stage('Deploy Staging') {
      agent { label 'staging' }
      steps {
        sh "kubectl set image deployment/${APP} ${APP}=${IMAGE_TAG} -n staging"
        sh "kubectl rollout status deployment/${APP} -n staging --timeout=5m"
      }
    }

    stage('Deploy Production') {
      when { branch 'main' }
      agent { label 'production' }
      steps {
        timeout(time: 1, unit: 'HOURS') {
          input(message: 'Deploy to production?', submitter: 'devops-leads')
        }
        sh "kubectl set image deployment/${APP} ${APP}=${IMAGE_TAG} -n production"
        sh "kubectl rollout status deployment/${APP} -n production --timeout=10m"
      }
    }
  }

  post {
    success { slackSend(color: 'good', message: "✅ ${APP} ${GIT_SHORT} deployed") }
    failure { slackSend(color: 'danger', message: "❌ ${APP} build failed: ${BUILD_URL}") }
    always  { cleanWs() }
  }
}

Troubleshooting Common Issues

"No such DSL method" — You used a scripted pipeline command inside a declarative block. Wrap it in script {}.

Credentials not masked in logs — Ensure you're using credentials() binding, not manually echoing secret values.

Build hangs at input — Set a timeout() wrapper around input steps.

Docker socket not available — Mount it explicitly: args '-v /var/run/docker.sock:/var/run/docker.sock'.

SCM checkout fails on agents — Ensure the agent node has Git installed and SSH keys configured for your repository.

Share:

Was this article helpful?

Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles

More in Jenkins

View all →

Discussion