intermediate10 min readPart 1 of 1

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.

TechLogHub
·

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

  • A Jenkins controller running in Docker with the official jenkins/jenkins:lts image, able to run Docker-based build steps.
  • A declarative pipeline with stages for checkout, install, test, build image, push and deploy.
  • Credentials for the registry and the production server, injected only where needed.
  • A multibranch pipeline that builds every branch and pull request but only deploys from main.
  • 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-agent

    And 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/initialAdminPassword

    Open 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:

  • Username with password, ID dockerhub: your registry username and an access token (not your account password).
  • SSH Username with private key, ID 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.

  • New Item, enter a name, choose Multibranch Pipeline.
  • Under Branch Sources, add Git (or GitHub if you installed the GitHub Branch Source plugin) and enter the repository URL and a credential that can read it.
  • Leave Build Configuration as "by Jenkinsfile" with script path Jenkinsfile.
  • Under Scan Multibranch Pipeline Triggers, set a periodic scan (for example every 15 minutes) as a fallback.
  • Save. Jenkins scans the repository and starts a build for each branch.
  • 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-1

    Create 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

  • Using double quotes around secrets in 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.
  • Expecting 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.
  • Holding an executor during 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.
  • Tagging only latest. You cannot roll back to "the previous latest". Tag every image with the commit SHA and deploy that tag.
  • Never updating plugins. Most Jenkins security advisories are for plugins. Update the controller and plugins regularly, and keep the plugin list short.
  • Forgetting the jenkins_home backup. Losing that volume loses every job, credential and build history.
  • Where to go next

  • Compare this setup with hosted CI in the GitHub Actions CI/CD tutorial and see GitHub Actions vs GitLab CI.
  • Deploy to a cluster instead of one server: replace the SSH step with kubectl set image or a Helm upgrade once you are comfortable with Kubernetes.
  • Brush up on branching workflows with the Git cheat sheet, since multibranch pipelines map directly onto them.
  • jenkinsci/cdjenkinsfiledockerdevops

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.