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-pagerExpected result: active (running). Open http://<controller>:8080 and unlock Jenkins with the password from:
sudo cat /var/lib/jenkins/secrets/initialAdminPasswordChoose 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-agentMembership 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_keysOn 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_hosts3. Add credentials and the agent
Under Manage Jenkins > Credentials > System > Global add two entries:
- SSH Username with private key, ID
agent-ssh, usernamejenkins-agent, the private key fromjenkins-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, credentialsagent-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 8080server {
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/
Jenkinsfile5. 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. withCredentialsexposes the registry password only inside that block, Jenkins masks it in the log, and--password-stdinkeeps it out of the process list.- The smoke test waits up to 30 seconds for
/healthzand fails the build, with the container log attached, if the new version does not come up. docker image prune --filter until=72hremoves only old dangling layers. The 2021 version randocker 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: SUCCESSOn the Docker host the new container is running and answering:
docker ps --filter name=demo-app
curl -s http://localhost:3000/healthzGotchas
- 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.
