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 sudo privileges.
  • 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.

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:

  1. Go to Manage Jenkins > Credentials.
  2. Under Stores scoped to Jenkins, click System, then Global credentials (unrestricted).
  3. Click Add Credentials and choose the kind Username with password.
  4. Enter your Docker Hub username as Username, the personal access token as Password, and dockerhub as ID.
  5. 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. H spreads 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: --pull always fetches the latest base image, so security fixes in python:3.12-slim reach 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 /health answers, which catches images that build but fail on startup. The trap removes the container whether the test passes or fails.
  • Push: withCredentials exposes the token only inside its block and masks it in the log. --password-stdin keeps it out of the process list. The sh scripts 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:

  1. Click New Item, enter hello-jenkins as the name, select Pipeline and click OK.
  2. In the Pipeline section, set Definition to Pipeline script from SCM.
  3. Set SCM to Git and enter the repository URL. Select the Git credentials if the repository is private.
  4. Set Branch Specifier to */main and leave Script Path as Jenkinsfile.
  5. 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: the jenkins user is not in the docker group, or Jenkins was not restarted after adding it (Step 1).
  • denied: requested access to the resource is denied on 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 match credentialsId in 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 df and schedule docker builder prune --force --filter until=168h in 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.