Mert Özen Further With Every Line
DevOps Jenkins — #02

Installing Jenkins: LTS with Docker, Java 21, the First Admin and Choosing Plugins

Mert Özen Nov 8, 2026 9 min 3 views
Installing Jenkins: LTS with Docker, Java 21, the First Admin and Choosing Plugins

You install Jenkins in Docker with a version-pinned LTS image, a named JENKINS_HOME volume and Java 21, and you skip the setup wizard with plugins.txt and JCasC. The anonymous volume trap, initialAdminPassword, the Docker-in-Docker TLS certificate error, anonymous access, zero executors on the controller, the CSRF crumb, agent-to-controller security and installing from the Linux package.

Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), Java 21 (Eclipse Temurin 21.0.13), Docker 29 and the Configuration as Code plugin 2147.

Guess First

To try Jenkins quickly you run this command: docker run -d --name jenkins -p 8080:8080 jenkins/jenkins:lts-jdk21. You did not pass a volume. For a week you set up jobs and added credentials. Then, to update the image, you remove the container and start it again with the same command. Are your jobs still there? The more interesting question: was that week of data deleted, or is it still sitting somewhere on disk?

Choosing the Image and the Tag

The official image is jenkins/jenkins. Its tags say two things together: the release line and the Java version. Some of the LTS tags on Docker Hub as I write this:

2.580.2-lts-jdk21   lts-jdk21   lts-slim-jdk21   lts-alpine-jdk21
2.580.2-lts-jdk25   lts-jdk25   lts-slim-jdk25
2.580.2-lts         lts         lts-slim         lts-alpine

lts-jdk21 moves with every new LTS release; today it is 2.580.2, a month from now 2.580.3. You do not want that in production: the day you pull the image again, your Jenkins version silently changes too. The tag that pins the version is 2.580.2-lts-jdk21; you upgrade deliberately, by changing the tag.

On the Java side the rule changed in the last year. According to the Jenkins Java support policy, Java 21 or Java 25 has been mandatory since 2.555.1; the last LTS supporting Java 17 was 2.541.3 (March 2026), and the Java 17 controller images were removed with 2.555.1. Java 25 support arrived with 2.541.1. For a new installation you pick jdk21 or jdk25; in this series I use jdk21. The Java inside the image:

$ docker exec jenkwork-plain java -version
openjdk version "21.0.13" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-21.0.13+9 (build 21.0.13+9-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.13+9 (build 21.0.13+9-LTS, mixed mode)

Where Does JENKINS_HOME Live?

The answer to the opening question is in the image metadata:

$ docker inspect jenkins/jenkins:2.580.2-lts-jdk21 --format '{{json .Config.Volumes}} {{json .Config.ExposedPorts}} {{.Config.User}}'
{"/var/jenkins_home":{}} {"50000/tcp":{},"8080/tcp":{}} jenkins

The image declares /var/jenkins_home as a volume. If you do not pass -v, Docker attaches an anonymous volume there. The test container I started without a volume indeed had this:

$ docker inspect jenkwork-plain --format '{{range .Mounts}}{{.Type}} {{.Name}} -> {{.Destination}}{{end}}'
volume 8298fdc14701cc9cacd6160afe94258d67e5058e9e3a1986f5a2e19debc17804 -> /var/jenkins_home

So when you run docker rm jenkins, the data is not deleted right away; it stays in a volume whose name is a random hash and which is attached to no container. But the new container you start with the same docker run command gets a new anonymous volume, and you see an empty Jenkins. If you ran with docker rm -v or --rm, the anonymous volume is gone as well. The result: a week of work is either deleted or lost inside one of dozens of hashes in docker volume ls. The right way is to pass a named volume from the start: -v jenkins-home:/var/jenkins_home.

First Start and the Setup Wizard

When you run the official image with a named volume, Jenkins starts the setup wizard and prints this box to the log (I removed the line with the password and the blank lines):

2026-11-08 03:49:46.431+0000 [id=48]	INFO	jenkins.install.SetupWizard#init:
[LF]> *************************************************************
[LF]> *************************************************************
[LF]> *************************************************************
[LF]> Jenkins initial setup is required. An admin user has been created and a password generated.
[LF]> Please use the following password to proceed to installation:
[LF]> This may also be found at: /var/jenkins_home/secrets/initialAdminPassword
[LF]> *************************************************************
[LF]> *************************************************************
[LF]> *************************************************************

The password is also in the file secrets/initialAdminPassword, with permissions that only the jenkins user can read:

-rw-r----- 1 jenkins jenkins 33 Nov  8 03:49 /var/jenkins_home/secrets/initialAdminPassword

Once you log in with this password in the browser, the wizard asks three things: "suggested plugins" or your own selection, the first admin user, and the Jenkins URL (Uniform Resource Locator). The suggested plugins list is an easy start but also brings many things you do not need. For a controller installed once by hand and never touched again, the wizard is enough. For a controller set up for a team, one that must be reproducible, it is better to skip the wizard entirely and describe everything in code. That is what we do in this series.

Code Instead of the Wizard: plugins.txt and JCasC

Our controller image adds two things on top of the official image: the plugin list and a Java property that turns off the wizard.

FROM jenkins/jenkins:2.580.2-lts-jdk21
ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false"
ENV CASC_JENKINS_CONFIG=/var/jenkins_casc
COPY --chown=jenkins:jenkins plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt

jenkins-plugin-cli is a tool that ships with the image; it downloads every line of plugins.txt together with its dependencies from the update center and puts them into the image. Because the plugins are downloaded at build time, the container does not need the internet on first start. The plugins we use in this series:

configuration-as-code:2147.v8a_8d38668c72
job-dsl:3732.v9a_c49a_61a_313
workflow-aggregator:608.v67378e9d3db_1
pipeline-graph-view:1053.v9df19e6ceed2
git:5.11.0
gitea:286.v0740a_413c1eb_
github-branch-source:1986.v801e40614d00
docker-workflow:653.v2f2c08eff0ec
credentials-binding:728.v902a_273b_8947
ssh-agent:433.v9e73d67c3f74
ssh-slaves:3.1097.v868116049892
junit:1458.v38661c0638e2
coverage:4.3567.v34a_294650f39
ws-cleanup:0.49
timestamper:1.30
lockable-resources:1582.v54e3b_a_8eda_3a_
matrix-auth:3.3

The Configuration as Code plugin (JCasC for short, Jenkins Configuration as Code) reads everything the wizard asks from a YAML (YAML Ain't Markup Language) file. CASC_JENKINS_CONFIG points to the folder that holds these files. The security-related part:

jenkins:
  systemMessage: "Bookstore CI - managed by JCasC, manual changes will be overwritten"
  numExecutors: 0
  mode: EXCLUSIVE
  securityRealm:
    local:
      allowsSignup: false
      users:
        - id: admin
          password: ${JENKINS_ADMIN_PASSWORD}
  authorizationStrategy:
    globalMatrix:
      entries:
        - user:
            name: admin
            permissions:
              - Overall/Administer
        - group:
            name: authenticated
            permissions:
              - Overall/Read
              - Job/Read
  crumbIssuer: standard

unclassified:
  location:
    url: http://localhost:18400/

${JENKINS_ADMIN_PASSWORD} is JCasC's variable interpolation: it reads the value from the container's environment. The password itself lives in a .env file that never goes into Git, and Docker Compose passes it to the container as an environment variable. In production this value should come from a secret store; that topic belongs to the upcoming Configuration and Secret Management series.

The Whole Environment in One File: compose.yaml

The controller and Docker-in-Docker part of the environment we use throughout the series:

name: jenkwork

services:
  jenkins:
    build: ./controller
    image: jenkwork/jenkins:2.580.2
    container_name: jenkwork-jenkins
    restart: unless-stopped
    ports:
      - "18400:8080"
    environment:
      JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD}
    volumes:
      - jenkins-home:/var/jenkins_home
      - ./controller/casc:/var/jenkins_casc:ro
      - ./secrets:/run/secrets:ro
    networks: [ci]

  dind:
    image: docker:29.9.3-dind
    container_name: jenkwork-dind
    privileged: true
    environment:
      DOCKER_TLS_CERTDIR: /certs
    volumes:
      - docker-certs:/certs/client
      - agent-workspace:/home/jenkins/agent
      - dind-data:/var/lib/docker
    networks:
      ci:
        aliases: [docker]

networks:
  ci:
    name: jenkwork-net

volumes:
  jenkins-home:
  docker-certs:
  agent-workspace:
  dind-data:

The Jenkins documentation uses docker run commands for the Docker installation; here I translated the same structure into Compose. Note that I do not publish port 50000: that port is for old-style TCP (Transmission Control Protocol) inbound agent connections. Our agents connect over WebSocket through 8080, as we will see in the fourth post.

The dind service is a separate Docker daemon so agents can build inside Docker. The aliases: [docker] line is not decoration. On my first attempt I connected the agent with DOCKER_HOST=tcp://jenkwork-dind:2376 and got this:

error during connect: Get "https://jenkwork-dind:2376/v1.56/version": tls: failed to verify certificate: x509: certificate is valid for 3f51d2f2099c, docker, localhost, not jenkwork-dind

The docker:dind image generates its own TLS (Transport Layer Security) certificate at startup and writes only the container hostname, docker and localhost into it. When the client cannot find the name it connected to in the certificate, it rejects the connection. That is why the official documentation has --network-alias docker; after adding the network alias and using DOCKER_HOST=tcp://docker:2376 the problem was solved.

First Security Settings

Whether you installed with the wizard or with code, check that these doors are closed before going to production.

Anonymous access must be off. This is what our controller answers when a client that has not logged in calls the REST (Representational State Transfer) API (Application Programming Interface):

$ curl -s -i http://localhost:18400/api/json | head -3
HTTP/1.1 403 Forbidden
Server: Jetty(12.1.14)
Date: Sun, 08 Nov 2026 03:57:44 GMT

The controller must have no executors. numExecutors: 0 takes care of that. A build running on the controller has access to the controller process's file system; that is, the secrets/ folder, the encrypted credentials and the key that decrypts them. The Jenkins documentation also explicitly recommends that builds do not run on the controller.

CSRF (Cross-Site Request Forgery) protection must be on. It is on by default in Jenkins and the UI (User Interface) has no option to turn it off; crumbIssuer: standard in JCasC puts it in writing. The first time I sent a request to the script console with curl, I ran straight into this protection:

<title>Error 403 No valid crumb was included in the request</title>

The same event shows up in the controller log:

WARNING	hudson.security.csrf.CrumbFilter#doFilter: No valid crumb was included in request for /scriptText by admin. Returning 403.

The fix is to first fetch a crumb from /crumbIssuer/api/json and send it as a header with the same session cookie, or to use an API token belonging to the user. Requests made with an API token do not need a crumb.

Agent-to-controller access control (agent → controller security) has been always on since 2.326 and cannot be turned off. If you see a remotingSecurity section in an old JCasC file, you can delete it on purpose. With matrix-based authorization (Matrix Authorization Strategy), giving the authenticated group read-only access and limiting administration to a few people is a good start.

Finally, the Jenkins URL. unclassified.location.url is where Jenkins knows its own address: links in e-mails, links in status checks sent to GitHub and agent connections use this value. When you put Jenkins behind a reverse proxy (such as Nginx), this value must be the HTTPS (HTTP Secure) address seen from outside. We will cover the reverse proxy and TLS setup in the upcoming Web Servers and Hosting series.

The Linux Package and the Windows MSI

Docker is not the only way. On a Debian or Ubuntu server, the LTS installation from the Jenkins documentation looks like this:

sudo apt update
sudo apt install fontconfig openjdk-21-jre
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 jenkins

The package creates a jenkins user and a systemd service, Jenkins opens on 8080, logs are read with journalctl -u jenkins.service, and JENKINS_HOME defaults to /var/lib/jenkins. Settings such as the port are changed with systemctl edit jenkins by adding an Environment="JENKINS_PORT=8081" line. On Windows, Jenkins comes as an MSI (Microsoft Installer) package and installs as a Windows service. This series was not run on Windows; we will only discuss Windows agents in the IIS (Internet Information Services) deploy in the ninth post. With the package installation, note that the key file name contains a year: the project renews its signing key periodically, and a key URL from an old blog post may not work today.

In Production

Memory: Jenkins is a JVM (Java Virtual Machine) application. In my local environment an empty controller used around 600 MB of memory; in a real organization with hundreds of jobs and dozens of concurrent pipelines this grows to several GB. Limit the heap with JAVA_OPTS and give the container enough memory.

Disk: JENKINS_HOME grows over time. Limit build history with buildDiscarder, and keep artifacts in an artifact repository rather than on the controller. In our environment even the plugins folder is 188 MB.

Upgrades: change the tag, rebuild the image, open it on a test controller first. Plugins depend on the Jenkins version, so an upgrade is always about "Jenkins plus plugins" together. We will cover this in detail in the eleventh post.

Backups: a named volume is not a backup in itself; if the volume is deleted or the disk fails, it is gone. Copy JENKINS_HOME out regularly and actually try restoring it.

Common Pitfalls

Running without a volume. As you saw above, the data is either lost or forgotten in a nameless volume.

Using latest or the moving lts tag in production. latest is the weekly line; lts-jdk21 moves with every LTS release. Both bring the "my Jenkins version changed the day I pulled the image" surprise.

Leaving an invalid field in the JCasC file. JCasC is strict: if it sees an attribute it does not know, it does not start Jenkins at all. On my first attempt I had written an old setting under crumbIssuer, and the controller stopped during boot with this error:

SEVERE  jenkins.InitReactorRunner$1#onTaskFailed: Failed ConfigurationAsCode.init
io.jenkins.plugins.casc.UnknownAttributesException: standard: Invalid configuration elements for type: class hudson.security.csrf.DefaultCrumbIssuer : excludeClientIPFromCrumb.

This strictness is actually a good thing: a mistyped security setting is not silently ignored. But it means you have to test a JCasC change before applying it to the production controller.

Exposing port 50000 to the internet. Agents using WebSocket do not need this port; an open port is an unnecessary attack surface.

Leaving executors on the controller. In a default installation the Built-In Node has two executors. The first job after installation is to set them to zero.

A Small Experiment

★☆☆ Run the jenkins/jenkins:2.580.2-lts-jdk21 image without a volume, create a job, remove the container and start it again. Then try to find the old data in the output of docker volume ls.

★★☆ Build your own controller image with the Dockerfile and plugins.txt from this post. Verify that the login page appears without the wizard and that the Built-In Node has zero executors in /computer/api/json.

★★★ Add an invalid field to your JCasC file on purpose, watch the controller fail to start and read the error in the log. Then design a check that would catch this error before it reaches production (hint: start the same image once in a CI (Continuous Integration) pipeline and wait for /login to return 200).

The controller is up and the security doors are closed. In the next post we write our first real Jenkinsfile: declarative syntax, stages, steps, post, environment, options and parameters.