Open this post in EchoOS →

Chapter 1.4 - Jenkins in Docker: Every Build in a Clean Container

A long-lived Jenkins server tends to accumulate things: three Node versions, two JDKs, a Maven someone upgraded by hand. Builds start depending on whatever happens to be installed, and nobody dares touch the machine.

Docker removes that problem at both ends:

How the pieces fit

The Jenkins container doesn’t run Docker inside itself. It gets the host’s Docker socket mounted in, so when a pipeline asks for a node:22 container, the host’s daemon starts it as a sibling of Jenkins:

flowchart TD
  dev([Developer]) -- "Build with Parameters" --> J
  G[(Git server)] -- checkout --> J
  subgraph Host["Server (Docker host)"]
    J["jenkins container · Docker CLI only"] -- "/var/run/docker.sock" --> D[(Host Docker daemon)]
    D -- "docker run" --> N["node:22 build container · workspace shared"]
    D -- "docker run" --> M["maven build container · ~/.m2 cache mounted"]
  end
  N -- "scp dist/" --> T[(Deploy target)]
  M -- "scp jar + ssh restart" --> T

A security note before you start. Access to the Docker socket is root access to the host: anything that can talk to it can start a privileged container that mounts /. Mounting it into Jenkins means that anyone who can edit a Jenkinsfile, or log in to Jenkins, effectively has root on this server. That’s acceptable for a team’s own build box; it’s not acceptable on a machine shared with things you need to protect. The alternative is dedicated build agents, where the controller never touches Docker itself.

1. A Jenkins image with the Docker CLI

The official image doesn’t include the docker command, and the plugins we need have to be installed. Create a Dockerfile:

FROM jenkins/jenkins:lts-jdk21

USER root

# Docker CLI only (no daemon): the static binary from download.docker.com
ARG DOCKER_VERSION=27.3.1
RUN curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz" \
    | tar -xz --strip-components=1 -C /usr/local/bin docker/docker

# The socket belongs to the host's "docker" group. The same GID inside the
# image lets the jenkins user use it without being root.
ARG DOCKER_GID=999
RUN (getent group ${DOCKER_GID} || groupadd -g ${DOCKER_GID} docker) \
    && usermod -aG ${DOCKER_GID} jenkins

USER jenkins

# Plugins baked into the image, so a rebuilt container comes up ready
RUN jenkins-plugin-cli --plugins \
    workflow-aggregator \
    docker-workflow \
    git \
    credentials-binding \
    ssh-credentials \
    junit

Line by line:

Build it with the host’s docker GID:

docker build \
  --build-arg DOCKER_GID="$(getent group docker | cut -d: -f3)" \
  -t my-jenkins:lts-jdk21 .

getent group docker prints docker:x:999:…; cut -d: -f3 takes the third colon-separated field, the GID. Run this on the server that will host Jenkins, or pass that server’s number.

2. Run it with Compose

Save as compose.yml next to the Dockerfile:

services:
  jenkins:
    image: my-jenkins:lts-jdk21
    restart: unless-stopped
    ports:
      - "8086:8080"
    volumes:
      - jenkins_home:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock

volumes:
  jenkins_home:
docker compose up -d
docker compose logs -f jenkins          # wait for "Jenkins is fully up and running"
docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

Open http://your-server:8086, paste the initial admin password, choose Install suggested plugins, and create your admin user. To check the Docker wiring, run docker compose exec jenkins docker ps: it should list the Jenkins container itself.

docker compose down stops and removes the container but keeps the jenkins_home volume; only down -v deletes it.

3. Credentials

Deployments need an SSH key that the target server accepts. Generate a dedicated one, never your personal key:

ssh-keygen -t ed25519 -f jenkins_deploy -C "jenkins deploy" -N ""
ssh-copy-id -i jenkins_deploy.pub deploy@203.0.113.20

In Jenkins, go to Manage Jenkins → Credentials → (global) → Add Credentials, choose SSH Username with private key, set the ID to deploy-key, the username to deploy, and paste the contents of jenkins_deploy. Add your Git credentials the same way if the repositories are private. Then delete the private key file from wherever you generated it.

4. A Node.js pipeline

Create a Pipeline job, set Definition to Pipeline script from SCM, point it at the application’s repository, and commit this as Jenkinsfile in that repository:

pipeline {
    agent {
        docker {
            image 'node:22'
            args '-v /var/cache/jenkins-npm:/tmp/npm-cache'
        }
    }

    parameters {
        choice(name: 'TARGET', choices: ['staging', 'production'], description: 'Where to deploy')
    }

    environment {
        DEPLOY_DIR = '/var/www/shop'
    }

    stages {
        stage('Install') {
            steps {
                sh 'npm ci --cache /tmp/npm-cache'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
        stage('Build') {
            steps {
                sh 'npm run build'
            }
        }
        stage('Deploy') {
            steps {
                script {
                    env.DEPLOY_HOST = params.TARGET == 'production' ? '203.0.113.20' : '203.0.113.21'
                }
                withCredentials([sshUserPrivateKey(credentialsId: 'deploy-key',
                                                   keyFileVariable: 'SSH_KEY',
                                                   usernameVariable: 'SSH_USER')]) {
                    sh '''
                        ssh -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new \
                            "$SSH_USER@$DEPLOY_HOST" "rm -rf $DEPLOY_DIR/next && mkdir -p $DEPLOY_DIR/next"
                        scp -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new -r dist/. \
                            "$SSH_USER@$DEPLOY_HOST:$DEPLOY_DIR/next/"
                        ssh -i "$SSH_KEY" "$SSH_USER@$DEPLOY_HOST" \
                            "rm -rf $DEPLOY_DIR/previous; mv $DEPLOY_DIR/current $DEPLOY_DIR/previous 2>/dev/null; mv $DEPLOY_DIR/next $DEPLOY_DIR/current"
                    '''
                }
            }
        }
    }

    post {
        success { echo "Deployed to ${params.TARGET}" }
        failure { echo 'Build failed; check the stage logs above' }
    }
}

Block by block:

5. A Java / Maven pipeline

For Maven there’s no need for a custom image: the official maven images bundle a JDK.

pipeline {
    agent {
        docker {
            image 'maven:3.9-eclipse-temurin-21'
            args '-v /var/cache/jenkins-m2:/var/maven/.m2'
        }
    }

    environment {
        DEPLOY_HOST = '203.0.113.20'
        APP_DIR     = '/opt/shop-api'
    }

    stages {
        stage('Build & test') {
            steps {
                sh 'mvn -B -Dmaven.repo.local=/var/maven/.m2/repository clean package'
            }
        }
        stage('Deploy') {
            steps {
                withCredentials([sshUserPrivateKey(credentialsId: 'deploy-key',
                                                   keyFileVariable: 'SSH_KEY',
                                                   usernameVariable: 'SSH_USER')]) {
                    sh '''
                        scp -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new \
                            target/shop-api.jar "$SSH_USER@$DEPLOY_HOST:$APP_DIR/shop-api.jar.new"
                        ssh -i "$SSH_KEY" "$SSH_USER@$DEPLOY_HOST" \
                            "mv $APP_DIR/shop-api.jar.new $APP_DIR/shop-api.jar && sudo systemctl restart shop-api"
                    '''
                }
            }
        }
    }

    post {
        always {
            junit allowEmptyResults: true, testResults: '**/target/surefire-reports/*.xml'
        }
    }
}

What’s different from the Node pipeline:

6. When the server can’t reach Docker Hub

Some build servers sit on networks without internet access, or with registry access blocked. Images can be carried over as files: docker save writes an image, with all its layers, to a tar archive, and docker load reads it back.

On a machine that can pull:

docker pull node:22
docker pull maven:3.9-eclipse-temurin-21
docker save -o build-images.tar node:22 maven:3.9-eclipse-temurin-21 my-jenkins:lts-jdk21
scp build-images.tar deploy@203.0.113.10:/tmp/

On the build server:

docker load -i /tmp/build-images.tar
docker image ls          # the images appear with their original names and tags

If you do this often, run your own registry inside the network instead (Chapter 1.2) and push the images there once.

Troubleshooting

Symptom Cause Fix
permission denied while trying to connect to the Docker daemon socket The docker group GID in the image doesn’t match the host’s. Rebuild with --build-arg DOCKER_GID=$(getent group docker \| cut -d: -f3).
docker: not found in the build log The Jenkins image has no Docker CLI. Use the custom image from step 1.
EACCES writing to the npm or .m2 cache The host cache directory isn’t owned by UID 1000. sudo chown -R 1000:1000 /var/cache/jenkins-npm.
Parameters missing on the first run Jenkins only learns about parameters {} by running the pipeline once. Run it once with the defaults.
Host key verification failed The target’s host key changed, or was never accepted. Check that it’s really your server, then update known_hosts.

Chapter 1.5 runs the other half of a self-hosted software pipeline: GitLab, with its container registry and CI runners.