Jenkins CI/CD Pipeline: Declarative Jenkinsfile from Test to Deploy
Build a Jenkins CI/CD pipeline step by step: Jenkins in Docker, a declarative Jenkinsfile with Docker agents, credentials, image push, and a deploy behind manual approval.
Prerequisites
- •A Git repository with a Node.js app and a Dockerfile
- •A Linux host with Docker and Docker Compose
- •Basic Git and Docker knowledge
What You'll Learn
- Run Jenkins in Docker with the official LTS image and Docker CLI access
- Write a declarative Jenkinsfile with stages, Docker agents, when conditions and post blocks
- Inject registry and SSH secrets safely with credentials binding
- Gate production deploys behind an input approval in a multibranch pipeline
Jenkins is still the most common self-hosted CI/CD server, and since the move to pipeline-as-code it is far less painful than its reputation. This tutorial builds a complete Jenkins CI/CD pipeline for a Node.js app: Jenkins running in Docker, a declarative Jenkinsfile that installs, tests, builds a Docker image, pushes it to a registry, and deploys to production after a human clicks Deploy. Every file is complete and copy-paste ready.
What you will build
jenkins/jenkins:lts image, able to run Docker-based build steps.If CI/CD is new to you, start with what a CI/CD pipeline is. If you are choosing between tools, the GitHub Actions version of this pipeline is a useful comparison.
Step 1: run Jenkins in Docker
The controller needs the Docker CLI so pipeline stages can start containers and build images. Create a folder called jenkins/ with this Dockerfile:
FROM jenkins/jenkins:lts-jdk21
USER root
RUN apt-get update && apt-get install -y ca-certificates curl && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && apt-get install -y docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
USER jenkins
RUN jenkins-plugin-cli --plugins docker-workflow pipeline-stage-view ssh-agentAnd a compose.yml next to it:
services:
jenkins:
build: .
container_name: jenkins
restart: unless-stopped
ports:
- "8080:8080"
- "50000:50000"
volumes:
- jenkins_home:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-999}"
volumes:
jenkins_home:Start it, passing the group ID that owns the Docker socket on your Linux host so the jenkins user can talk to Docker:
DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up -d --build
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPasswordOpen http://localhost:8080, paste the password, choose Install suggested plugins, and create your admin user. The jenkins_home volume holds all jobs, credentials and plugins, so back it up.
Mounting the Docker socket gives Jenkins root-equivalent access to the host. That is acceptable on a dedicated build machine; in a shared environment, run builds on separate agents instead (covered below). If you need a Docker refresher, keep the Docker cheat sheet open.
Step 2: add credentials
Never put secrets in the Jenkinsfile. Go to Manage Jenkins > Credentials > System > Global credentials > Add credentials and create two entries:
dockerhub: your registry username and an access token (not your account password).prod-ssh: username deploy and the private key that can log in to your production server.The ID is what the pipeline refers to. Jenkins stores the value encrypted and masks it in console output.
Step 3: write the Jenkinsfile
Commit this file as Jenkinsfile at the root of your repository:
pipeline {
agent any
options {
skipDefaultCheckout(true)
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20'))
disableConcurrentBuilds()
}
environment {
IMAGE = 'docker.io/youruser/web'
npm_config_cache = "${WORKSPACE}/.npm"
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.TAG = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
}
}
}
stage('Install') {
agent {
docker {
image 'node:22-alpine'
reuseNode true
}
}
steps {
sh 'npm ci'
}
}
stage('Test') {
agent {
docker {
image 'node:22-alpine'
reuseNode true
}
}
steps {
sh 'npm run lint'
sh 'npm test'
}
post {
always {
junit allowEmptyResults: true, testResults: 'reports/junit.xml'
}
}
}
stage('Build image') {
when { branch 'main' }
steps {
sh 'docker build -t $IMAGE:$TAG -t $IMAGE:latest .'
}
}
stage('Push') {
when { branch 'main' }
steps {
withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) {
sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin'
sh 'docker push $IMAGE:$TAG'
sh 'docker push $IMAGE:latest'
}
}
}
stage('Deploy') {
when { branch 'main' }
options {
timeout(time: 1, unit: 'HOURS')
}
input {
message 'Deploy this build to production?'
ok 'Deploy'
submitter 'admin,release-managers'
}
steps {
sshagent(credentials: ['prod-ssh']) {
sh 'ssh -o StrictHostKeyChecking=accept-new [email protected] "docker pull $IMAGE:$TAG && IMAGE_TAG=$TAG docker compose -f /srv/app/compose.yml up -d"'
}
}
}
}
post {
success {
echo "Build ${env.BUILD_NUMBER} succeeded for ${env.BRANCH_NAME}"
}
failure {
echo "Build failed: ${env.BUILD_URL}"
}
always {
sh 'docker logout || true'
deleteDir()
}
}
}How the pieces fit
agent any runs the pipeline on any available executor. The Install and Test stages override it with a docker agent, so Node runs inside node:22-alpine and you never install Node on the Jenkins machine. reuseNode true keeps the same workspace, so node_modules from Install is there for Test.npm_config_cache points npm's cache into the workspace. Without it, npm inside the container tries to write to a home directory the Jenkins user does not own and npm ci fails with a permissions error.skipDefaultCheckout(true) plus an explicit Checkout stage makes the checkout visible in the stage view and lets you capture the short commit SHA as TAG.when { branch 'main' } skips build, push and deploy on feature branches and pull requests. Other useful conditions are changeRequest() for pull requests, buildingTag() for tag builds, and expression { ... } for anything custom.withCredentials exposes REG_USER and REG_PASS only inside its block. Note the single quotes around the sh strings: the shell expands $REG_PASS, not Groovy, so the secret never lands in the Groovy string or the build log.input pauses the Deploy stage until someone in the submitter list approves. The stage-level timeout aborts the build if nobody responds within an hour.post runs after all stages. always cleans up the workspace with the built-in deleteDir(); success and failure are where you would add Slack or email notifications.The production server keeps a compose.yml that references ${IMAGE_TAG} in its image line, so passing a new tag and running docker compose up -d swaps the container to the new version.
Step 4: create a multibranch pipeline
A plain Pipeline job builds one branch. A multibranch pipeline scans your repository, creates a sub-job for every branch and pull request that contains a Jenkinsfile, and removes them when branches are deleted. The when { branch 'main' } conditions only work in multibranch jobs, because that is where BRANCH_NAME is set.
Jenkinsfile.For instant builds, add a webhook in your Git host pointing at Jenkins. With the GitHub plugin the URL is https://jenkins.example.com/github-webhook/. Webhooks need Jenkins to be reachable from the internet, so put it behind a reverse proxy with TLS.
Step 5: move builds off the controller
Running builds on the controller is fine for a first pipeline, but production setups give the controller zero executors and send work to agents. The quickest route is an inbound agent container on another host:
docker run -d --name jenkins-agent --restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
jenkins/inbound-agent \
-url https://jenkins.example.com -workDir /home/jenkins/agent \
-secret YOUR_AGENT_SECRET -name docker-agent-1Create the node first under Manage Jenkins > Nodes > New Node (type "Permanent Agent", launch method "inbound"); Jenkins then shows the secret to use. Give the node a label such as docker and change the top of your Jenkinsfile to agent { label 'docker' }. Then set the built-in node's executor count to 0. The agent image also needs the Docker CLI for the build stages, so extend it the same way as the controller image in Step 1.
Common mistakes
sh. sh "docker login -p ${REG_PASS}" interpolates the secret in Groovy, and Jenkins warns about it for a reason. Use single quotes and let the shell expand the variable.when { branch } to work in a plain Pipeline job. It silently skips the stage because BRANCH_NAME is empty. Use a multibranch job, or test env.GIT_BRANCH in an expression.input. With agent any at the top, the executor stays busy while the build waits for approval. On busy servers, use agent none at the top and declare agents per stage, so the input stage waits without consuming one.latest. You cannot roll back to "the previous latest". Tag every image with the commit SHA and deploy that tag.jenkins_home backup. Losing that volume loses every job, credential and build history.Where to go next
kubectl set image or a Helm upgrade once you are comfortable with Kubernetes.
