Jenkins Controller Setup
Install, harden, and configure a Jenkins LTS controller on Ubuntu 24.04 for a private-network CI environment.
Part 1 of 7
- Jenkins Controller Setup
- SonarQube & Quality Gates
- Wiring the Toolchain
- Jenkinsfile Anatomy
- Multi-Client Deploys
- Gate Enforcement
- Troubleshooting
Target state
| OS | Ubuntu 24.04 LTS |
| Jenkins | 2.555.x LTS (Debian package, official repo) |
| Java | OpenJDK 17 — required by Jenkins 2.4xx+ |
| Reverse proxy | Nginx, TLS terminated at the proxy |
| Exposure | Private network only. No inbound from internet. |
| Home | /var/lib/jenkins — owned by the jenkins user |
Installation
Jenkins 2.4xx and later require Java 17 or 21. Install the headless JDK — no GUI libraries needed on a server.
# Update and install OpenJDK 17
sudo apt update
sudo apt install -y fontconfig openjdk-17-jre-headless
# Verify
java -version
# openjdk version "17.0.x" ...
fontconfig is not optional — Jenkins uses it for chart and badge rendering, and its absence produces confusing runtime errors later.
# Add the Jenkins signing key
sudo wget -O /usr/share/keyrings/jenkins-keyring.asc \
https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key
# Add the LTS repository
echo "deb [signed-by=/usr/share/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 -y jenkins
# Start and enable at boot
sudo systemctl enable --now jenkins
sudo systemctl status jenkins --no-pager
Use debian-stable (LTS), not debian (weekly). LTS gets security backports and is the only sensible choice for a system that gates production deploys.
# Initial admin password
sudo cat /var/lib/jenkins/secrets/initialAdminPassword
Browse to http://<host>:8080, paste the password, choose Select plugins to install rather than the suggested set — the suggested list installs a lot you won't use, and each plugin is attack surface plus an upgrade obligation.
Plugin set
The minimum needed for the pipeline in this series. Install via Manage Jenkins → Plugins → Available.
| Plugin | Why |
|---|---|
Pipeline (workflow-aggregator) | Declarative pipeline support. Everything else depends on it. |
Git | SCM checkout and polling. |
NodeJS | Per-job Node version provisioning via the tools block. |
SonarQube Scanner | Provides withSonarQubeEnv and waitForQualityGate. |
SSH Agent | Injects an SSH key into the build for deploy steps. |
Email Extension (email-ext) | emailext step — richer than the built-in mailer. |
Credentials Binding | withCredentials for secrets in shell steps. |
Timestamper | Timestamps in console output. Invaluable when debugging slow stages. |
Folders | Folder-scoped credentials and job organisation (UAT / Staging / Production). |
UAT folder is only resolvable by jobs in that folder. This is how you stop a staging job from being able to authenticate against production.Hardening
Set executors on the controller to 0
By default Jenkins runs builds on the controller itself. That means build scripts execute with access to /var/lib/jenkins — including credentials.xml and the master key. Any pipeline can then read every secret Jenkins holds.
Manage Jenkins → Nodes → Built-In Node → Configure → Number of executors → 0
Waiting for next available executor. Set up an agent before setting this to 0, or you will block all builds. For a small internal setup, a Docker-based agent or a single SSH agent node is enough.
Security realm and authorization
Manage Jenkins → Security
- Security Realm — Jenkins' own user database. Uncheck Allow users to sign up.
- Authorization — Matrix-based security, or Role-Based Strategy plugin if you need per-folder roles. Never leave it on Anyone can do anything.
- CSRF Protection — enabled (default). Leave it on.
- Agent → Controller Access Control — enabled.
- CLI over Remoting — disabled.
Nginx reverse proxy with TLS
# /etc/nginx/sites-available/jenkins
upstream jenkins { server 127.0.0.1:8080 fail_timeout=0; }
server {
listen 443 ssl http2;
server_name jenkins.example.internal;
ssl_certificate /etc/ssl/certs/jenkins.crt;
ssl_certificate_key /etc/ssl/private/jenkins.key;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location / {
proxy_pass http://jenkins;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect http:// https://;
# Required for the Jenkins UI's live console streaming
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_request_buffering off;
}
}
# /etc/nginx/nginx.conf — inside the http{} block
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Upgrade / Connection map, WebSocket-dependent parts of the Jenkins UI silently break — plugin install progress bars hang, and some buttons never appear. The page loads fine, so it looks like a UI bug rather than a proxy config problem.Then bind Jenkins to localhost only, so the proxy is the only path in:
# /etc/default/jenkins (or systemd override on newer packages)
JENKINS_LISTEN_ADDRESS=127.0.0.1
# Tell Jenkins its external URL
# Manage Jenkins → System → Jenkins Location → Jenkins URL
# https://jenkins.example.internal/
Firewall
sudo ufw allow from 10.10.0.0/16 to any port 443 proto tcp
sudo ufw deny 8080
sudo ufw enable
sudo ufw status verbose
Global tool configuration
Manage Jenkins → Tools → NodeJS installations → Add NodeJS
Add one entry per Node version your projects actually run. The Name is what the Jenkinsfile references in its tools block, so keep it predictable:
| Name | Version | Used by |
|---|---|---|
Node.js 24.11.x | 24.11.1 | Projects pinned to 24.11.1 in .nvmrc |
Node.js 24.18.x | 24.18.0 | Most current-generation client APIs |
Node.js 20.20.x | 20.20.2 | Legacy project (documented EOL exception) |
'Node.js 24.18.x' fails with an unhelpful error if the tool is actually named 'Node.js 24.18'. Match strings exactly, and treat the tool name as an interface.Build retention
Set on every job (or in the Jenkinsfile options block, covered in Part 4):
options {
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds()
timestamps()
}
buildDiscarder— 30 builds is enough history to bisect a regression without filling the disk with archived artifacts and console logs.disableConcurrentBuilds— essential when the pipeline deploys. Two simultaneous builds racing togit reset --hardandpm2 restartthe same directory will corrupt the deployment.timestamps— every console line gets a timestamp. Free, and makes "which stage is slow" answerable.
Verification checklist
| # | Check | Command / location |
|---|---|---|
| 1 | Service running and enabled | systemctl is-enabled jenkins && systemctl is-active jenkins |
| 2 | Java version correct | java -version → 17 or 21 |
| 3 | Not listening on public interface | ss -lntp | grep 8080 → bound to 127.0.0.1 |
| 4 | TLS works via proxy | curl -I https://jenkins.example.internal/login |
| 5 | Anonymous access denied | Open in a private window → redirected to login |
| 6 | Node tools resolve | Create a throwaway pipeline job with tools { nodejs '...' } and sh 'node -v' |
| 7 | Jenkins URL set | Manage Jenkins → System → Jenkins Location — must not be blank, or email links break |