Jenkins Declarative Pipelines: CI/CD with Docker Agents
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 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.
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:
agent anyagent none
No global agent — each stage must declare its own:
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:
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:
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):
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
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:
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:
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:
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:
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:
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:
when {
expression { return params.RUN_TESTS }
}Stages and Steps
Common steps
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
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
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):
when {
beforeAgent true
branch 'main'
}Parallel Stages
Run stages simultaneously to speed up the pipeline:
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:
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.
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
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
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
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:
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):
@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
- Helmfile: Managing Multiple Helm Releases — coordinate Helm charts the same way pipelines coordinate stages
- Kubernetes Core Concepts — Deployments and rollouts that Jenkins pipelines update
Official References
- Jenkins Pipeline — declarative and scripted pipeline syntax
- Dockerfile best practices — layer caching, image size and build ordering
- Dockerfile reference — every instruction and its semantics
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.