Mert Özen Her Satırda Daha İleri
DevOps Jenkins — #02

Jenkins Kurulumu: Docker ile LTS, Java 21, İlk Admin ve Plugin Seçimi

Mert Özen 8 Kas 2026 9 dk 5 görüntülenme
Jenkins Kurulumu: Docker ile LTS, Java 21, İlk Admin ve Plugin Seçimi

Jenkins'i Docker'da sürümü sabit bir LTS image'ı, isimli bir JENKINS_HOME volume'ü ve Java 21 ile kurarsın; setup wizard'ı plugins.txt ve JCasC ile atlarsın. Anonim volume tuzağı, initialAdminPassword, Docker-in-Docker'ın TLS sertifika hatası, anonymous erişim, controller'da sıfır executor, CSRF crumb'ı, agent → controller security ve Linux paketiyle kurulum.

Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), Java 21 (Eclipse Temurin 21.0.13), Docker 29 ve Configuration as Code plugin'i 2147 ile denenmiştir.

Önce Tahmin Et

Jenkins'i hızlıca denemek için şu komutu çalıştırıyorsun: docker run -d --name jenkins -p 8080:8080 jenkins/jenkins:lts-jdk21. Volume vermedin. Bir hafta boyunca job'lar kurdun, credential'lar ekledin. Sonra image'ı güncellemek için container'ı silip aynı komutla yeniden başlatıyorsun. Job'ların yerinde mi? Daha ilginç soru: o bir haftalık veri silindi mi, yoksa diskte bir yerde hâlâ duruyor mu?

Image ve Tag Seçimi

Resmi image jenkins/jenkins. Tag'ler iki şeyi birlikte söyler: sürüm hattı ve Java sürümü. Bu yazıyı yazarken Docker Hub'daki LTS tag'lerinden bazıları:

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 her yeni LTS sürümüyle birlikte kayar; bugün 2.580.2, bir ay sonra 2.580.3. Production'da bunu istemezsin: image'ı yeniden çektiğin gün Jenkins'in sürümü de sessizce değişir. Sürümü sabitleyen tag 2.580.2-lts-jdk21; upgrade'i bilinçli olarak, tag'i değiştirerek yaparsın.

Java tarafında kural son bir yılda değişti. Jenkins'in Java destek politikasına göre 2.555.1'den beri Java 21 veya Java 25 zorunlu; Java 17'yi destekleyen son LTS 2.541.3 (Mart 2026) idi ve Java 17'li controller image'ları da 2.555.1 ile kaldırıldı. Java 25 desteği 2.541.1 ile geldi. Yeni kurulumda jdk21 ya da jdk25 seçersin; ben bu seride jdk21 kullanıyorum. Image'ın içindeki Java:

$ 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)

JENKINS_HOME Nerede Yaşıyor?

Baştaki sorunun cevabı image'ın metadata'sında:

$ 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

Image /var/jenkins_home'u volume olarak tanımlıyor. Sen -v vermezsen Docker oraya anonim bir volume bağlar. Volume vermeden başlattığım deneme container'ında gerçekten de şu vardı:

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

Yani docker rm jenkins dediğinde veri hemen silinmez; adı rastgele bir hash olan, hiçbir container'a bağlı olmayan bir volume'de kalır. Ama aynı docker run komutuyla başlattığın yeni container yeni bir anonim volume alır ve boş bir Jenkins görürsün. docker rm -v ya da --rm ile çalıştırdıysan anonim volume da gider. Sonuç: bir haftalık iş ya silinmiştir ya da docker volume ls listesindeki onlarca hash'ten birinin içinde kaybolmuştur. Doğrusu baştan isimli bir volume vermek: -v jenkins-home:/var/jenkins_home.

İlk Çalıştırma ve Setup Wizard

İsimli volume ile resmi image'ı çalıştırdığında Jenkins setup wizard'ı başlatır ve log'a şu kutuyu basar (parolanın yazdığı satırı ve boş satırları ben çıkardım):

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]> *************************************************************

Parola, aynı zamanda secrets/initialAdminPassword dosyasında, yalnız jenkins kullanıcısının okuyabileceği izinlerle duruyor:

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

Tarayıcıdan bu parolayla girince wizard üç şey sorar: "suggested plugins" mi yoksa kendi seçimin mi, ilk admin kullanıcısı ve Jenkins URL'i (Uniform Resource Locator). Suggested plugins listesi kolay bir başlangıç ama ihtiyacın olmayan birçok şeyi de getirir. Bir kez elle kurulup sonra dokunulmayan bir controller için wizard yeterli. Ekip için kurulan, yeniden kurulabilir olması gereken bir controller içinse wizard'ı tamamen atlayıp her şeyi kodla tarif etmek daha iyi. Bu seride öyle yapıyoruz.

Wizard Yerine Kod: plugins.txt ve JCasC

Controller image'ımız resmi image'ın üzerine iki şey ekliyor: plugin listesi ve wizard'ı kapatan bir Java property'si.

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 image'la birlikte gelen bir araç; plugins.txt'teki her satırı bağımlılıklarıyla birlikte update center'dan indirip image'ın içine koyar. Plugin'ler build sırasında indiği için container ilk açılışta internete muhtaç değildir. Bu seride kullandığımız plugin'ler:

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

Wizard'ın sorduğu her şeyi Configuration as Code plugin'i (kısaca JCasC, Jenkins Configuration as Code) bir YAML (YAML Ain't Markup Language) dosyasından okur. CASC_JENKINS_CONFIG bu dosyaların bulunduğu klasörü gösteriyor. Güvenlikle ilgili kısmı:

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} JCasC'nin variable interpolation'ı: değeri container'ın environment'ından okur. Parolanın kendisi Git'e girmeyen bir .env dosyasında duruyor ve Docker Compose onu container'a environment variable olarak veriyor. Production'da bu değer bir secret store'dan gelmeli; o konu ilerideki Configuration ve Secret Management serisinde.

Bütün Ortam Tek Dosyada: compose.yaml

Seri boyunca kullandığımız ortamın controller ve Docker-in-Docker kısmı:

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:

Jenkins dokümantasyonu Docker ile kurulumda docker run komutları kullanıyor; burada aynı yapıyı Compose'a çevirdim. Port 50000'i açmadığıma dikkat et: o port eski usul TCP (Transmission Control Protocol) inbound agent bağlantıları için. Bizim agent'larımız WebSocket ile 8080 üzerinden bağlanıyor, dördüncü yazıda göreceğiz.

dind servisi agent'ların Docker içinde build alabilmesi için ayrı bir Docker daemon'ı. aliases: [docker] satırı süs değil. İlk denemede agent'ı DOCKER_HOST=tcp://jenkwork-dind:2376 ile bağladım ve şunu aldım:

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

docker:dind image'ı açılışta kendi TLS (Transport Layer Security) sertifikasını üretiyor ve sertifikaya yalnız container hostname'ini, docker ve localhost adlarını yazıyor. İstemci bağlandığı adı sertifikada bulamayınca bağlantıyı reddediyor. Resmi dokümandaki --network-alias docker bu yüzden var; network alias'ı ekleyip DOCKER_HOST=tcp://docker:2376 kullanınca sorun çözüldü.

İlk Güvenlik Ayarları

Wizard ile kurduysan da kodla kurduysan da production'a çıkmadan önce şu kapıların kapalı olduğunu kontrol et.

Anonymous erişim kapalı olmalı. Giriş yapmamış bir istemci REST (Representational State Transfer) API'ye (Application Programming Interface) geldiğinde bizim controller'ın verdiği cevap:

$ 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

Controller'da executor olmamalı. numExecutors: 0 bunu sağlıyor. Controller'da koşan bir build, controller process'inin dosya sistemine erişir; yani secrets/ klasörüne, şifrelenmiş credential'lara ve onları çözen anahtara. Jenkins dokümantasyonu da build'lerin controller'da koşmamasını açıkça öneriyor.

CSRF (Cross-Site Request Forgery) koruması açık olmalı. Jenkins'te varsayılan olarak açık ve UI'da (User Interface) kapatma seçeneği yok; JCasC'deki crumbIssuer: standard bunu yazılı hâle getiriyor. Script console'a ilk kez curl ile istek attığımda tam olarak bu korumaya takıldım:

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

Controller'ın log'unda da aynı olay görünüyor:

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

Çözüm, önce /crumbIssuer/api/json'dan crumb alıp aynı session cookie'siyle header olarak göndermek ya da kullanıcıya ait bir API token kullanmak. API token ile yapılan isteklerde crumb gerekmez.

Agent'tan controller'a erişim kontrolü (agent → controller security) 2.326'dan beri her zaman açık ve kapatılamıyor. Eski bir JCasC dosyasında remotingSecurity bölümü görürsen bilerek silebilirsin. Matrix tabanlı yetkilendirmede (Matrix Authorization Strategy) ise authenticated grubuna yalnız okuma yetkisi verip yönetimi birkaç kişiyle sınırlamak iyi bir başlangıç.

Son olarak Jenkins URL'i. unclassified.location.url Jenkins'in kendi adresini bildiği yer: e-postalardaki linkler, GitHub'a gönderilen status check'lerin linkleri ve agent bağlantıları bu değeri kullanır. Jenkins'i bir reverse proxy'nin (Nginx gibi) arkasına koyduğunda bu değer dışarıdan görünen HTTPS (HTTP Secure) adresi olmalı. Reverse proxy ve TLS kurulumunu ilerideki Web Server'lar ve Hosting serisinde anlatacağız.

Linux Paketi ve Windows MSI

Docker tek yol değil. Debian veya Ubuntu bir sunucuda Jenkins dokümantasyonunun LTS kurulumu şöyle:

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

Paket bir jenkins kullanıcısı ve systemd service'i oluşturur, Jenkins 8080'de açılır, log'lar journalctl -u jenkins.service ile okunur, JENKINS_HOME varsayılan olarak /var/lib/jenkins'tir. Port gibi ayarlar systemctl edit jenkins ile Environment="JENKINS_PORT=8081" satırı eklenerek değiştirilir. Windows'ta Jenkins bir MSI (Microsoft Installer) paketiyle gelir ve Windows service olarak kurulur. Bu seri Windows'ta çalıştırılmadı; yalnız Windows agent'ları dokuzuncu yazıdaki IIS (Internet Information Services) deploy'unda konuşacağız. Paket kurulumunda key dosyasının adında yıl olduğuna dikkat et: proje imza anahtarını dönemsel olarak yeniliyor, eski bir blog yazısındaki key URL'i bugün çalışmayabilir.

Üretimde Dikkat

Bellek: Jenkins bir JVM (Java Virtual Machine) uygulaması. Benim yerel ortamımda boş bir controller 600 MB civarında bellek kullanıyordu; gerçek bir kurumda yüzlerce job ve onlarca eşzamanlı pipeline ile bu birkaç GB'a çıkar. JAVA_OPTS ile heap'i sınırla ve container'a yeterli bellek ver.

Disk: JENKINS_HOME zamanla büyür. Build geçmişini buildDiscarder ile sınırla, artifact'ları controller'da değil bir artifact repository'de tut. Bizim ortamda plugin klasörü bile 188 MB.

Upgrade: tag'i değiştir, image'ı yeniden build et, önce bir test controller'ında aç. Plugin'ler Jenkins sürümüne bağlı olduğu için upgrade her zaman "Jenkins + plugin'ler" birlikte düşünülür. Bunu on birinci yazıda ayrıntılı ele alacağız.

Yedek: isimli volume'ün kendisi yedek değildir; volume silinirse ya da disk bozulursa gider. JENKINS_HOME'u düzenli olarak dışarıya kopyala ve geri yüklemeyi gerçekten dene.

Sık Düşülen Tuzaklar

Volume vermeden çalıştırmak. Yukarıda gördüğün gibi veri ya kaybolur ya da isimsiz bir volume'de unutulur.

Production'da latest veya kayan lts tag'i kullanmak. latest weekly hattıdır; lts-jdk21 ise her LTS sürümünde kayar. İkisi de "image'ı çektiğim gün Jenkins sürümüm değişti" sürprizini getirir.

JCasC dosyasında geçersiz bir alan bırakmak. JCasC katıdır: tanımadığı bir attribute görürse Jenkins'i hiç açmaz. İlk denememde crumbIssuer altına eski bir ayar yazmıştım ve controller boot sırasında şu hatayla durdu:

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.

Bu katılık aslında iyi bir şey: yanlış yazılmış bir güvenlik ayarı sessizce yok sayılmaz. Ama JCasC değişikliğini production controller'a uygulamadan önce test etmen gerektiği anlamına geliyor.

Port 50000'i internete açmak. WebSocket kullanan agent'lar bu porta ihtiyaç duymaz; açık kalan bir port gereksiz bir saldırı yüzeyidir.

Controller'a executor bırakmak. Varsayılan kurulumda Built-In Node'un iki executor'ı vardır. Kurulumdan sonraki ilk iş onları sıfırlamak.

Küçük Bir Deneme

★☆☆ jenkins/jenkins:2.580.2-lts-jdk21 image'ını volume vermeden çalıştır, bir job oluştur, container'ı sil ve yeniden başlat. Sonra docker volume ls çıktısında eski veriyi bulmaya çalış.

★★☆ Bu yazıdaki Dockerfile ve plugins.txt ile kendi controller image'ını build et. Wizard açılmadan login sayfasının geldiğini ve /computer/api/json'da Built-In Node'un sıfır executor'ı olduğunu doğrula.

★★★ JCasC dosyana bilerek geçersiz bir alan ekle, controller'ın açılmadığını gör, log'daki hatayı oku. Sonra bu hatayı production'a gitmeden yakalayacak bir kontrol tasarla (ipucu: aynı image'ı CI (Continuous Integration) pipeline'ında bir kez açıp /login'in 200 döndüğünü beklemek).

Controller ayakta, güvenlik kapıları kapalı. Bir sonraki yazıda ilk gerçek Jenkinsfile'ı yazıyoruz: declarative syntax, stages, steps, post, environment, options ve parameters.