Jenkins pipelines are defined in a Jenkinsfile that lives in the same repository as your code, so the build process is versioned and reviewed like everything else. In this tutorial you will give Jenkins access to Docker on an Ubuntu 24.04 server, store registry credentials safely, and write a declarative pipeline that builds an image, runs the tests inside it, checks that the container starts, and pushes it to Docker Hub or a private registry. A small Python application is used as the example, but the pipeline works the same for any project with a Dockerfile.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS with at least 2 GB of RAM (4 GB recommended), for example a CubePath VPS, and a non-root user with
sudoprivileges. - Jenkins LTS installed from the official Jenkins repository, following the Linux instructions at
jenkins.io/doc/book/installing/linux/, with the suggested plugins (they include Pipeline, Git and Credentials Binding) and an admin account. - Docker Engine installed on the same server from Docker's official repository.
- A Git repository (GitHub, GitLab or similar) that Jenkins can clone.
- A Docker Hub account and a personal access token with read and write access, or credentials for a private registry.
Step 1 - Giving Jenkins access to Docker
The pipeline runs docker commands as the jenkins system user, which needs permission to use the Docker socket. Add it to the docker group and restart Jenkins so the new group membership takes effect:
sudo usermod -aG docker jenkins
sudo systemctl restart jenkins
Verify that the jenkins user can talk to the Docker daemon:
sudo -u jenkins docker version --format '{{.Server.Version}}'
28.4.0
If you get permission denied while trying to connect to the Docker daemon socket, Jenkins was not restarted or the group was not added.
WarningMembership in the
dockergroup is equivalent to root access on the host, because any job can start a privileged container. This is acceptable when only trusted people can change pipelines. For shared Jenkins installations, run Docker builds on a dedicated build agent instead of the controller.
Step 2 - Preparing the example application
If you are using your own project, skip to Step 3. Otherwise, create these three files at the root of your repository.
app.py is a minimal web server with a health endpoint:
from http.server import BaseHTTPRequestHandler, HTTPServer
def message(path):
if path == "/health":
return "ok"
return "Hello from Jenkins"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = (message(self.path) + "\n").encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
test_app.py contains its unit tests:
import unittest
from app import message
class MessageTest(unittest.TestCase):
def test_health(self):
self.assertEqual(message("/health"), "ok")
def test_root(self):
self.assertIn("Hello", message("/"))
if __name__ == "__main__":
unittest.main()
The Dockerfile builds the image and runs the application as a non-root user:
FROM python:3.12-slim
WORKDIR /app
COPY app.py test_app.py ./
RUN useradd --system --no-create-home app
USER app
EXPOSE 8000
CMD ["python", "app.py"]
Before involving Jenkins, check that the image builds and the tests pass on your machine:
docker build -t hello-jenkins:dev .
docker run --rm hello-jenkins:dev python -m unittest -v
test_health (test_app.MessageTest.test_health) ... ok
test_root (test_app.MessageTest.test_root) ... ok
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
Step 3 - Storing the registry credentials in Jenkins
The pipeline must log in to the registry without the token appearing in the Jenkinsfile or in the build log. Jenkins' credentials store handles both.
In the Jenkins web interface:
- Go to Manage Jenkins > Credentials.
- Under Stores scoped to Jenkins, click System, then Global credentials (unrestricted).
- Click Add Credentials and choose the kind Username with password.
- Enter your Docker Hub username as Username, the personal access token as Password, and
dockerhubas ID. - Click Create.
The ID is how the pipeline refers to these credentials. If the repository is private, add a second credential for Git the same way (a token or an SSH key) to select when you create the job.
Step 4 - Writing the Jenkinsfile
Create a file called Jenkinsfile at the root of the repository. Replace your_dockerhub_user with your Docker Hub username:
pipeline {
agent any
options {
buildDiscarder(logRotator(numToKeepStr: '20'))
disableConcurrentBuilds()
timeout(time: 30, unit: 'MINUTES')
}
triggers {
pollSCM('H/5 * * * *')
}
environment {
IMAGE = 'your_dockerhub_user/hello-jenkins'
TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Build') {
steps {
sh 'docker build --pull --label org.opencontainers.image.revision="$GIT_COMMIT" -t "$IMAGE:$TAG" .'
}
}
stage('Unit tests') {
steps {
sh 'docker run --rm "$IMAGE:$TAG" python -m unittest -v'
}
}
stage('Smoke test') {
steps {
sh '''
cid=$(docker run -d "$IMAGE:$TAG")
trap 'docker rm -f "$cid" > /dev/null' EXIT
for i in $(seq 1 10); do
if docker exec "$cid" python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"; then
exit 0
fi
sleep 1
done
docker logs "$cid"
exit 1
'''
}
}
stage('Push') {
steps {
withCredentials([usernamePassword(credentialsId: 'dockerhub',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_TOKEN')]) {
sh '''
echo "$REGISTRY_TOKEN" | docker login -u "$REGISTRY_USER" --password-stdin
docker tag "$IMAGE:$TAG" "$IMAGE:latest"
docker push "$IMAGE:$TAG"
docker push "$IMAGE:latest"
'''
}
}
}
}
post {
always {
sh 'docker logout || true'
sh 'docker image rm "$IMAGE:$TAG" "$IMAGE:latest" || true'
}
}
}
What each part does:
agent any: runs on any available executor, here the Jenkins server itself, where Docker is installed.options: keeps the last 20 builds, prevents two builds of this job from running at the same time (they would share the Docker login) and aborts builds that hang.triggers: checks the repository for new commits about every 5 minutes.Hspreads the checks so all jobs do not poll at the same minute.environment: the image name and a tag based on the Jenkins build number, so every build produces a unique, traceable image. The Git commit is stored as an OCI label.- Build:
--pullalways fetches the latest base image, so security fixes inpython:3.12-slimreach your builds. - Unit tests: runs the tests inside the image you just built, with exactly the dependencies that will go to production.
- Smoke test: starts the container and waits until
/healthanswers, which catches images that build but fail on startup. Thetrapremoves the container whether the test passes or fails. - Push:
withCredentialsexposes the token only inside its block and masks it in the log.--password-stdinkeeps it out of the process list. Theshscripts use single quotes so the shell, not Groovy, expands the variables, which is what Jenkins recommends for secrets. post { always }: logs out and removes the local images so the server's disk does not fill up with one image per build.
Commit and push all the files:
git add Jenkinsfile Dockerfile app.py test_app.py
git commit -m "Add Docker build pipeline"
git push origin main
Step 5 - Creating the pipeline job
In Jenkins, create a job that reads its pipeline from the repository:
- Click New Item, enter
hello-jenkinsas the name, select Pipeline and click OK. - In the Pipeline section, set Definition to Pipeline script from SCM.
- Set SCM to Git and enter the repository URL. Select the Git credentials if the repository is private.
- Set Branch Specifier to
*/mainand leave Script Path asJenkinsfile. - Click Save.
Jenkins registers the pollSCM trigger from the Jenkinsfile the first time the job runs, so start the first build by hand with Build Now.
Step 6 - Running the pipeline and verifying the image
Open the build from Build History and click Console Output. A successful run ends like this:
+ docker push your_dockerhub_user/hello-jenkins:1
The push refers to repository [docker.io/your_dockerhub_user/hello-jenkins]
...
1: digest: sha256:4f6c1e... size: 1573
...
+ docker logout
Removing login credentials for https://index.docker.io/v1/
...
Finished: SUCCESS
The stage view on the job page shows each stage in green with its duration. Now check the published image from any machine with Docker:
docker run -d --rm --name hello-test -p 8000:8000 your_dockerhub_user/hello-jenkins:1
curl http://localhost:8000/
Hello from Jenkins
Stop the test container with docker stop hello-test. From now on, every commit pushed to main is picked up within five minutes and produces a new tagged image.
To build immediately on each push instead of polling, install the GitHub plugin, check GitHub hook trigger for GITScm polling in the job, and add a webhook in the repository pointing to https://jenkins.your_domain/github-webhook/.
Pushing to a private registry
To push to a self-hosted registry such as Harbor or the Docker registry, only two things change. Create a Username with password credential with the registry account (for example with the ID private-registry), then prefix the image name with the registry host and pass the host to docker login and docker logout:
environment {
IMAGE = 'registry.your_domain/your_project/hello-jenkins'
TAG = "${env.BUILD_NUMBER}"
}
withCredentials([usernamePassword(credentialsId: 'private-registry',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_TOKEN')]) {
sh '''
echo "$REGISTRY_TOKEN" | docker login registry.your_domain -u "$REGISTRY_USER" --password-stdin
docker tag "$IMAGE:$TAG" "$IMAGE:latest"
docker push "$IMAGE:$TAG"
docker push "$IMAGE:latest"
'''
}
In post, change the logout to docker logout registry.your_domain || true. The registry must have a valid TLS certificate; Docker refuses to push over plain HTTP by default.
Troubleshooting
permission denied while trying to connect to the Docker daemon socket: thejenkinsuser is not in thedockergroup, or Jenkins was not restarted after adding it (Step 1).denied: requested access to the resource is deniedon push: the image name does not start with your Docker Hub username or organization, or the access token is read-only.ERROR: Could not find credentials entry with ID 'dockerhub': the credential ID in Jenkins does not matchcredentialsIdin the Jenkinsfile, or it was created in a folder the job cannot see.- The server runs out of disk space: build cache and base images still accumulate. Check with
docker system dfand scheduledocker builder prune --force --filter until=168hin cron or a systemd timer.
Conclusion
You now have a pipeline, stored in the repository, that builds a Docker image on every commit, tests it inside the container, verifies that it starts and pushes a uniquely tagged image to a registry without exposing the token. Next, add an image vulnerability scan stage before the push, move Docker builds to a dedicated Jenkins agent, or add a deploy stage that pulls the new tag on your servers over SSH.