Part ofCI/CD Pipelines·Step 3 of 3
DevOps & Platform

Jenkins Declarative Pipelines: CI/CD with Docker Agents

Intermediate80 min to complete20 min readJune 1, 2026Updated August 19, 2026

Quick answer

Write maintainable Jenkins pipelines with the declarative syntax — stages, Docker agents, credentials, parallel jobs, and post actions. Includes a complete build-test-push-deploy Jenkinsfile.

intermediate · 80 min

Before you begin

  • Running Jenkins instance (2.400+ recommended)
  • Docker installed on Jenkins agent nodes
  • Git repository to connect
Jenkins
CI/CD
Pipelines
Docker
Automation
DevOps

Jenkins Declarative Pipelines: CI/CD with Docker Agents

Jenkins pipelines define your entire CI/CD process as code in a Jenkinsfile committed to the repository. Two syntaxes exist: Declarative (structured, validated, easier to read) and Scripted (Groovy-based, fully flexible, harder to maintain). Declarative is the right default for most teams.

This tutorial covers declarative pipelines exclusively.


The Jenkinsfile

A Jenkinsfile lives in the root of the repository. Jenkins reads it automatically for Multibranch Pipeline and GitHub Organization projects.

groovy
1// Jenkinsfile
2pipeline {
3    agent any
4
5    stages {
6        stage('Build') {
7            steps {
8                sh 'echo "Building..."'
9            }
10        }
11    }
12}

Top-level structure:

pipeline {
    agent { ... }           // Where to run
    environment { ... }     // Environment variables
    options { ... }         // Pipeline-level settings
    parameters { ... }      // Input parameters
    triggers { ... }        // Scheduled / webhook triggers
    stages {
        stage('Name') {
            agent { ... }   // Stage-level agent (optional override)
            environment { ... }
            when { ... }    // Conditional execution
            steps { ... }   // The actual work
            post { ... }    // Stage-level post actions
        }
    }
    post { ... }            // Pipeline-level post actions
}

Agent Declaration

The agent directive tells Jenkins where to run the pipeline.

agent any

Run on any available Jenkins agent:

groovy
agent any

agent none

No global agent — each stage must declare its own:

groovy
1pipeline {
2    agent none
3    stages {
4        stage('Test') {
5            agent { label 'linux' }
6            steps { sh 'npm test' }
7        }
8    }
9}

Docker agent

Run the stage inside a Docker container — the container is started fresh for each stage, the workspace is mounted into it:

groovy
1agent {
2    docker {
3        image 'node:20-alpine'
4        args '-u root'              // Optional Docker run args
5        reuseNode true              // Run on same node as pipeline agent
6    }
7}

Requirements: the Docker Pipeline plugin and Docker installed on the agent node.

Dockerfile agent

Build the Docker image from a Dockerfile in the repo:

groovy
1agent {
2    dockerfile {
3        filename 'Dockerfile.ci'          // Relative to dir (below)
4        dir 'docker'                      // Build context directory; filename is relative to this
5        additionalBuildArgs '--build-arg VERSION=1.0'
6    }
7}

Kubernetes agent

Run in a Kubernetes pod (requires Kubernetes plugin):

groovy
1agent {
2    kubernetes {
3        yaml '''
4apiVersion: v1
5kind: Pod
6spec:
7  containers:
8  - name: node
9    image: node:20-alpine
10    command: [sleep, infinity]
11  - name: docker
12    image: docker:dind
13    securityContext:
14      privileged: true
15'''
16        defaultContainer 'node'
17    }
18}

Environment Variables

Pipeline-level

groovy
1pipeline {
2    environment {
3        APP_NAME = 'my-api'
4        REGISTRY = 'ghcr.io/myorg'
5        IMAGE_TAG = "${env.GIT_COMMIT[0..6]}"    // First 7 chars of commit hash
6    }
7}

Using credentials

The credentials() helper binds secrets from the Jenkins credentials store:

groovy
1environment {
2    // Username/password credential — sets MY_CRED_USR and MY_CRED_PSW
3    DOCKER_CREDS = credentials('docker-hub-creds')
4
5    // Secret text — sets the variable directly
6    NPM_TOKEN = credentials('npm-publish-token')
7
8    // SSH private key — writes key to a temp file, sets path in variable
9    DEPLOY_KEY = credentials('deploy-ssh-key')
10}

Use in steps:

groovy
steps {
    sh 'docker login -u $DOCKER_CREDS_USR -p $DOCKER_CREDS_PSW docker.io'
    sh 'npm publish --access public'    // NPM_TOKEN auto-used via .npmrc
}

withCredentials block

Scope credentials to a single step:

groovy
1steps {
2    withCredentials([
3        string(credentialsId: 'slack-token', variable: 'SLACK_TOKEN'),
4        usernamePassword(
5            credentialsId: 'aws-creds',
6            usernameVariable: 'AWS_ACCESS_KEY_ID',
7            passwordVariable: 'AWS_SECRET_ACCESS_KEY'
8        )
9    ]) {
10        sh 'aws s3 cp dist/ s3://my-bucket/ --recursive'
11    }
12}

Options

Pipeline-level settings:

groovy
1options {
2    timeout(time: 30, unit: 'MINUTES')    // Abort if pipeline exceeds 30 minutes
3    retry(2)                              // Retry failed pipeline up to 2 times
4    disableConcurrentBuilds()             // Prevent parallel runs of the same pipeline
5    buildDiscarder(logRotator(numToKeepStr: '10'))   // Keep only last 10 builds
6    timestamps()                          // Prefix log lines with timestamps
7    skipDefaultCheckout()                 // Don't auto-checkout SCM at start
8}

Parameters

Accept input at trigger time:

groovy
parameters {
    string(name: 'TARGET_ENV', defaultValue: 'staging', description: 'Deploy target')
    booleanParam(name: 'RUN_TESTS', defaultValue: true, description: 'Run test suite')
    choice(name: 'LOG_LEVEL', choices: ['info', 'debug', 'warn'], description: 'Log level')
}

Use with params.PARAM_NAME:

groovy
when {
    expression { return params.RUN_TESTS }
}

Stages and Steps

Common steps

groovy
1steps {
2    // Shell command (Unix)
3    sh 'npm ci'
4    sh '''
5        set -e
6        npm run lint
7        npm test
8    '''
9
10    // Echo
11    echo "Building ${env.APP_NAME}:${env.IMAGE_TAG}"
12
13    // Checkout SCM (usually automatic)
14    checkout scm
15
16    // Archive artifacts
17    archiveArtifacts artifacts: 'dist/**', fingerprint: true
18
19    // Publish test results
20    junit 'reports/**/*.xml'
21
22    // Read/write files
23    def content = readFile('config.json')
24    writeFile(file: 'output.txt', text: 'done')
25
26    // Execute a script block (Groovy)
27    script {
28        def props = readJSON file: 'package.json'
29        env.APP_VERSION = props.version
30    }
31}

The sh step

groovy
1// Return stdout
2def output = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
3
4// Return exit code (don't fail on non-zero)
5def exitCode = sh(script: 'docker inspect my-image', returnStatus: true)
6if (exitCode != 0) {
7    echo 'Image not found, will build from scratch'
8}

when — Conditional Stage Execution

groovy
1stage('Deploy to Production') {
2    when {
3        branch 'main'                          // Only on main branch
4    }
5    steps { ... }
6}
7
8stage('Deploy to Staging') {
9    when {
10        not { branch 'main' }                  // All branches except main
11    }
12    steps { ... }
13}
14
15stage('Integration Tests') {
16    when {
17        environment name: 'RUN_INTEGRATION', value: 'true'
18    }
19    steps { ... }
20}
21
22stage('Release') {
23    when {
24        allOf {
25            branch 'main'
26            expression { return params.RELEASE == true }
27        }
28    }
29    steps { ... }
30}
31
32stage('Tag Build') {
33    when {
34        anyOf {
35            branch 'main'
36            branch 'release/*'
37        }
38    }
39    steps { ... }
40}
41
42stage('Check Change') {
43    when {
44        changeset 'src/**'    // Only if files matching this pattern changed
45    }
46    steps { ... }
47}

By default, when is evaluated after the agent is started. To evaluate before (and skip the agent start):

groovy
when {
    beforeAgent true
    branch 'main'
}

Parallel Stages

Run stages simultaneously to speed up the pipeline:

groovy
1stage('Test') {
2    parallel {
3        stage('Unit Tests') {
4            agent { docker { image 'node:20-alpine' } }
5            steps {
6                sh 'npm run test:unit'
7            }
8        }
9        stage('Lint') {
10            agent { docker { image 'node:20-alpine' } }
11            steps {
12                sh 'npm run lint'
13            }
14        }
15        stage('Type Check') {
16            agent { docker { image 'node:20-alpine' } }
17            steps {
18                sh 'npx tsc --noEmit'
19            }
20        }
21    }
22}

failFast: true — abort all parallel stages if one fails:

groovy
1stage('Test') {
2    failFast true
3    parallel {
4        stage('Unit') { ... }
5        stage('Integration') { ... }
6    }
7}

Post Actions

post blocks run after stages complete, regardless of success or failure.

groovy
1pipeline {
2    agent any
3    stages { ... }
4
5    post {
6        always {
7            // Runs after every build — cleanup, notifications
8            cleanWs()    // Delete workspace (requires Workspace Cleanup plugin)
9        }
10        success {
11            slackSend(
12                channel: '#deployments',
13                color: 'good',
14                message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} succeeded"
15            )
16        }
17        failure {
18            slackSend(
19                channel: '#deployments',
20                color: 'danger',
21                message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} failed — ${env.BUILD_URL}"
22            )
23            emailext(
24                subject: "Pipeline Failed: ${env.JOB_NAME}",
25                body: "Build URL: ${env.BUILD_URL}",
26                to: '[email protected]'
27            )
28        }
29        unstable {
30            // Tests passed but with warnings (e.g., some tests marked unstable)
31        }
32        aborted {
33            echo 'Pipeline was manually aborted'
34        }
35        changed {
36            // Status changed from previous build (fail → success or success → fail)
37            echo 'Build status changed!'
38        }
39    }
40}

A Complete Pipeline: Build, Test, Push, Deploy

groovy
1// Jenkinsfile
2pipeline {
3    agent none
4
5    environment {
6        REGISTRY     = 'ghcr.io/myorg'
7        APP_NAME     = 'my-api'
8        DOCKER_CREDS = credentials('ghcr-creds')
9    }
10
11    options {
12        timeout(time: 20, unit: 'MINUTES')
13        disableConcurrentBuilds()
14        buildDiscarder(logRotator(numToKeepStr: '20'))
15    }
16
17    parameters {
18        booleanParam(name: 'DEPLOY_TO_PROD', defaultValue: false, description: 'Promote this build to production')
19    }
20
21    stages {
22        stage('Test') {
23            agent { docker { image 'node:20-alpine' } }
24            steps {
25                sh 'npm ci'
26                sh 'npm run lint'
27                sh 'npm test -- --reporter=junit --outputFile=test-results.xml'
28            }
29            post {
30                always {
31                    junit 'test-results.xml'
32                }
33            }
34        }
35
36        stage('Build Image') {
37            agent any
38            steps {
39                script {
40                    env.IMAGE_TAG = sh(
41                        script: 'git rev-parse --short HEAD',
42                        returnStdout: true
43                    ).trim()
44                    env.FULL_IMAGE = "${env.REGISTRY}/${env.APP_NAME}:${env.IMAGE_TAG}"
45                }
46                sh '''
47                    docker build \
48                        --build-arg APP_VERSION=${IMAGE_TAG} \
49                        -t ${FULL_IMAGE} \
50                        -t ${REGISTRY}/${APP_NAME}:latest \
51                        .
52                '''
53            }
54        }
55
56        stage('Push Image') {
57            agent any
58            when { branch 'main' }
59            steps {
60                sh 'echo $DOCKER_CREDS_PSW | docker login ghcr.io -u $DOCKER_CREDS_USR --password-stdin'
61                sh 'docker push ${FULL_IMAGE}'
62                sh 'docker push ${REGISTRY}/${APP_NAME}:latest'
63            }
64        }
65
66        stage('Deploy to Staging') {
67            agent any
68            when { branch 'main' }
69            steps {
70                withCredentials([
71                    file(credentialsId: 'kubeconfig-staging', variable: 'KUBECONFIG')
72                ]) {
73                    sh '''
74                        kubectl set image deployment/my-api \
75                            my-api=${FULL_IMAGE} \
76                            -n staging
77                        kubectl rollout status deployment/my-api -n staging --timeout=120s
78                    '''
79                }
80            }
81        }
82
83        stage('Deploy to Production') {
84            agent any
85            when {
86                allOf {
87                    branch 'main'
88                    expression { return params.DEPLOY_TO_PROD == true }
89                }
90            }
91            input {
92                message "Deploy ${env.IMAGE_TAG} to production?"
93                ok "Deploy"
94            }
95            steps {
96                withCredentials([
97                    file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')
98                ]) {
99                    sh '''
100                        kubectl set image deployment/my-api \
101                            my-api=${FULL_IMAGE} \
102                            -n production
103                        kubectl rollout status deployment/my-api -n production --timeout=300s
104                    '''
105                }
106            }
107        }
108    }
109
110    post {
111        success {
112            echo "Pipeline complete — image: ${env.FULL_IMAGE}"
113        }
114        failure {
115            echo "Pipeline failed — check ${env.BUILD_URL}"
116        }
117        always {
118            sh 'docker logout ghcr.io || true'
119        }
120    }
121}

Input Step — Manual Approval Gate

groovy
1stage('Approve Production Deploy') {
2    steps {
3        input {
4            message "Deploy to production?"
5            ok "Yes, deploy"
6            submitter "ops-team"               // Only these users can approve
7            parameters {
8                string(name: 'REASON', description: 'Reason for deployment')
9            }
10        }
11    }
12}

The pipeline pauses and waits for a human to click "Yes, deploy" in the Jenkins UI.


Shared Libraries

Shared libraries let multiple pipelines reuse common code. Stored in a separate Git repository with this structure:

shared-library/
├── vars/
│   └── deployToK8s.groovy     # Global variable (callable as deployToK8s(...))
└── src/
    └── com/myorg/
        └── Docker.groovy       # Groovy class
groovy
1// vars/deployToK8s.groovy
2def call(Map config) {
3    sh """
4        kubectl set image deployment/${config.app} \
5            ${config.app}=${config.image} \
6            -n ${config.namespace}
7        kubectl rollout status deployment/${config.app} \
8            -n ${config.namespace} \
9            --timeout=${config.timeout ?: '120s'}
10    """
11}

Configure the library in Jenkins (Manage Jenkins → Configure System → Global Pipeline Libraries), then use it:

groovy
1// Jenkinsfile
2@Library('my-shared-library') _
3
4pipeline {
5    agent any
6    stages {
7        stage('Deploy') {
8            steps {
9                deployToK8s(
10                    app: 'my-api',
11                    image: "${REGISTRY}/${APP_NAME}:${IMAGE_TAG}",
12                    namespace: 'production',
13                    timeout: '300s'
14                )
15            }
16        }
17    }
18}

Script Security

Jenkins runs pipelines inside a Groovy sandbox. Certain operations are blocked by default and require approval.

org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException:
Scripts not permitted to use method ...

To approve: Manage Jenkins → In-process Script Approval → approve the specific method.

Alternatively, use the @NonCPS annotation for methods that don't need the sandbox (serializable-only operations):

groovy
@NonCPS
def parseVersion(String pom) {
    def matcher = pom =~ /<version>(.+?)<\/version>/
    return matcher[0][1]
}

Frequently Asked Questions

Declarative or scripted pipelines?

Declarative for almost everything — the structure is validated, the syntax is readable, and restart-from-stage works. Scripted gives full Groovy when you genuinely need arbitrary logic. A declarative pipeline with a script block for the awkward part is usually better than going fully scripted.

How should credentials be handled?

Through the credentials binding rather than environment variables you construct yourself, so Jenkins masks them in logs. Note masking is best-effort — a credential echoed after transformation can still leak. Prefer short-lived cloud credentials over long-lived ones stored in Jenkins.

Why did my pipeline not pick up the Jenkinsfile change?

Usually the branch indexing has not run, or the job is pinned to a specific revision. For multibranch pipelines, scan the repository to re-index. Also check the job is reading the Jenkinsfile from the branch you edited rather than a default.

Should agents run on Kubernetes?

It suits Jenkins well — each build gets a fresh pod, so there is no state accumulating on long-lived agents and capacity scales with demand. The trade is that your CI now depends on the cluster, and pod startup adds latency to every build compared with a warm agent.

What's Next

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.