Skip to content

How to Build and Deploy Docker Images with a Jenkins Pipeline (2026)

Last updated on October 26, 2026

Updated October 2026. The 2021 version of this post installed Jenkins with apt-key and Java 11, built images in a Freestyle job and wiped the agent with docker system prune -af on every build. None of that holds up today. This version uses a declarative Pipeline from a Jenkinsfile, a Docker agent connected over SSH with host key verification, credentials from the Jenkins store and a registry push. Everything below was run end to end on Ubuntu 24.04 with Jenkins 2.580.1 LTS, Java 21 and Docker 29.

The layout: a Jenkins controller that does not run builds itself, and one Docker host connected as an agent with the label docker. The pipeline builds the image there, pushes it to a registry and starts the new container on the same host.

1. Install Jenkins LTS

Jenkins needs Java 21. Ubuntu 24.04 and Debian 13 ship it; Debian 12 does not, so use Debian 13 or a Temurin build there. The repository key changed in 2026, and apt-key is gone, so the key goes into a keyring referenced with signed-by:

sudo apt update
sudo apt install -y fontconfig openjdk-21-jre

sudo install -d /etc/apt/keyrings
sudo wget -O /etc/apt/keyrings/jenkins-keyring.asc \
  https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key
echo "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/" \
  | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null

sudo apt update
sudo apt install -y jenkins
systemctl status jenkins --no-pager

Expected result: active (running). Open http://<controller>:8080 and unlock Jenkins with the password from:

sudo cat /var/lib/jenkins/secrets/initialAdminPassword

Choose Install suggested plugins and create the admin user. The suggested set already contains everything this post needs: Pipeline, Git, Credentials Binding and SSH Build Agents.

2. Prepare the Docker host

On the machine that will build and run the containers, install Docker, an SSH server and the same Java version (the agent process is Java too), then create a dedicated user:

sudo apt install -y docker.io openssh-server openjdk-21-jre-headless
sudo useradd -m -s /bin/bash jenkins-agent
sudo usermod -aG docker jenkins-agent

Membership in the docker group is effectively root on that host, which is why the agent gets its own user and its own machine, not the controller.

Create an SSH key pair for Jenkins and authorise it on the agent:

ssh-keygen -t ed25519 -N "" -C jenkins-agent -f jenkins-agent
sudo install -d -m 700 -o jenkins-agent -g jenkins-agent /home/jenkins-agent/.ssh
sudo install -m 600 -o jenkins-agent -g jenkins-agent jenkins-agent.pub /home/jenkins-agent/.ssh/authorized_keys

On the controller, record the agent’s host key, so Jenkins can verify it instead of trusting whatever answers on port 22:

sudo -u jenkins mkdir -p /var/lib/jenkins/.ssh
ssh-keyscan -t ed25519 docker-01.example.com | sudo -u jenkins tee -a /var/lib/jenkins/.ssh/known_hosts

3. Add credentials and the agent

Under Manage Jenkins > Credentials > System > Global add two entries:

  • SSH Username with private key, ID agent-ssh, username jenkins-agent, the private key from jenkins-agent.
  • Username with password, ID registry-creds, the account that may push to your registry.

Then Manage Jenkins > Nodes > New Node, name docker-01, type Permanent Agent:

  • Remote root directory: /home/jenkins-agent
  • Labels: docker
  • Launch method: Launch agents via SSH, host docker-01.example.com, credentials agent-ssh
  • Host Key Verification Strategy: Known hosts file Verification Strategy
  • Availability: Keep this agent online as much as possible

Expected result: the agent log ends with Agent successfully connected and online. Set the number of executors on the built-in node to 0, so builds never run on the controller.

4. The application

Any project with a Dockerfile works. To keep the example small, this is a static page served by nginx with a health endpoint the pipeline can test:

FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 8080
server {
    listen 8080;
    root /usr/share/nginx/html;

    location = /healthz {
        access_log off;
        return 200 "ok\n";
    }
}

And a .dockerignore, so the build context does not include the Git history:

.git/
Jenkinsfile

5. The Jenkinsfile

Put this in the root of the repository as Jenkinsfile and change REGISTRY to your registry:

pipeline {
  agent { label 'docker' }

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

  environment {
    REGISTRY = 'registry.example.com:5000'
    IMAGE    = "${REGISTRY}/demo-app"
    TAG      = "${env.BUILD_NUMBER}"
  }

  stages {
    stage('Build') {
      steps {
        sh 'docker build --pull -t "$IMAGE:$TAG" .'
      }
    }

    stage('Push') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'registry-creds',
                                          usernameVariable: 'REG_USER',
                                          passwordVariable: 'REG_PASS')]) {
          sh '''
            echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
            docker push "$IMAGE:$TAG"
            docker tag "$IMAGE:$TAG" "$IMAGE:latest"
            docker push "$IMAGE:latest"
          '''
        }
      }
    }

    stage('Deploy') {
      steps {
        sh '''
          docker rm -f demo-app || true
          docker run -d --name demo-app --restart unless-stopped -p 3000:8080 "$IMAGE:$TAG"
        '''
      }
    }

    stage('Smoke test') {
      steps {
        sh '''
          for i in $(seq 1 15); do
            curl -fsS http://localhost:3000/healthz && exit 0
            sleep 2
          done
          echo "demo-app did not become healthy"; docker logs --tail 50 demo-app; exit 1
        '''
      }
    }
  }

  post {
    always {
      sh 'docker logout "$REGISTRY" || true'
      sh 'docker image prune -f --filter "until=72h"'
    }
  }
}

What each part does:

  • agent { label 'docker' } runs everything on the Docker host, never on the controller.
  • Images are tagged with the build number and also as latest, so every build can be traced and rolled back by tag.
  • withCredentials exposes the registry password only inside that block, Jenkins masks it in the log, and --password-stdin keeps it out of the process list.
  • The smoke test waits up to 30 seconds for /healthz and fails the build, with the container log attached, if the new version does not come up.
  • docker image prune --filter until=72h removes only old dangling layers. The 2021 version ran docker system prune -af, which also deleted every other image and stopped container on the host.

6. Create the job and run it

New Item > Pipeline, then under Pipeline choose Pipeline script from SCM, SCM Git, your repository URL and credentials, branch */main and script path Jenkinsfile. Save and press Build Now.

Expected result in the console output, in this order:

[Pipeline] { (Build)
Successfully tagged registry.example.com:5000/demo-app:2
[Pipeline] { (Push)
Login Succeeded
2: digest: sha256:4d05c026c047... size: 2481
latest: digest: sha256:4d05c026c047... size: 2481
[Pipeline] { (Deploy)
[Pipeline] { (Smoke test)
ok
Removing login credentials for registry.example.com:5000
Finished: SUCCESS

On the Docker host the new container is running and answering:

docker ps --filter name=demo-app
curl -s http://localhost:3000/healthz

Gotchas

  • Never use the non-verifying host key strategy. It lets anything that answers on the agent address receive your builds and credentials. Known hosts takes one ssh-keyscan.
  • Java on the agent matters. The agent runs the same remoting library as the controller; an old JRE on the Docker host keeps it offline with a version error.
  • Do not build on the controller. A build that can run Docker can read /var/lib/jenkins, including every stored secret.
  • Registry over plain HTTP only works for localhost. For anything else put TLS in front of the registry instead of adding it to Docker’s insecure registries.
  • One deployment at a time. disableConcurrentBuilds() stops two builds from replacing the same container at once.

Takeaways

  • Jenkins 2.580 LTS on Java 21, installed from the keyring-signed repository.
  • A Docker agent over SSH with a verified host key, labelled docker.
  • A declarative Jenkinsfile: build, push with masked credentials, deploy, smoke test, careful cleanup.

Links

Published inCI/CD & AutomationDocker