Jenkins out of the box is a Java app that listens on http://your-server:8080. That works for a five-minute tire-kick on a laptop. It doesn't work for anything you'd want to point a real team at — there's no TLS, the URL is ugly, port 8080 conflicts with everything else that wants to use it, and the box is one stray firewall rule away from being publicly exposed in the clear.
The standard production setup for Jenkins on Linux is the same shape it has been for a decade: bind Jenkins to 127.0.0.1:8080, put Nginx in front of it on :443, terminate TLS at Nginx with a Let's Encrypt certificate, and forward requests to Jenkins as a reverse proxy. That gives you a clean https://jenkins.example.com URL, modern TLS without touching Jenkins' Java keystore, and the option to share :443 with anything else you want on the same host.
This guide does that end-to-end on Ubuntu 20.04 LTS, including the parts that aren't in most quick-start blog posts: the exact reverse-proxy headers Jenkins needs (and what breaks if you forget them), how to clear the "It appears that your reverse proxy setup is broken" warning, the WebSocket configuration required for inbound agents and the build console, the firewall rules, and what to do when something doesn't load.
The same procedure works on Ubuntu 22.04 and 24.04 — only the JDK package name differs (openjdk-17-jre instead of openjdk-11-jre). Notes inline where it matters.
Prerequisites
Before you start, you need:
- An Ubuntu 20.04 LTS server with 2 GB RAM minimum (4 GB recommended once you actually run builds). Jenkins is a Java app and the JVM is hungry.
- A user with
sudoprivileges. (See How to add a user to sudoers in AlmaLinux or Rocky Linux — the equivalent on Ubuntu isusermod -aG sudo <user>, since Debian-family distros use thesudogroup rather thanwheel.) - A domain name with an A record pointing at the server's public IP. Self-signed certificates work for testing, but Let's Encrypt requires a real DNS name. This guide assumes
jenkins.example.com. - Inbound TCP/80 and TCP/443 open from the public internet (Let's Encrypt's HTTP-01 challenge needs port 80; users need port 443).
- Outbound HTTPS so the server can reach
apt.jenkins.io,adoptium.net, andacme-v02.api.letsencrypt.org.
Make sure DNS actually resolves before you run Certbot.
dig +short jenkins.example.comshould return your server's IP. The HTTP-01 challenge fails if DNS doesn't answer, and the failure messages are not always obvious.
Update the system first:
sudo apt update && sudo apt upgrade -y
sudo reboot # only if a kernel was updated
Step 1: Install Java
Jenkins is written in Java. Jenkins LTS 2.426+ requires Java 11 or Java 17 — Java 8 is no longer supported. Install Adoptium's OpenJDK 11 (or 17) from Ubuntu's archive:
sudo apt install -y openjdk-11-jre-headless
On Ubuntu 22.04 and later, install
openjdk-17-jre-headlessinstead. Jenkins will work with either.
Verify:
java -version
# openjdk version "11.0.21" 2023-10-17
# OpenJDK Runtime Environment (build 11.0.21+9-post-Ubuntu-0ubuntu1.20.04)
# OpenJDK 64-Bit Server VM (build 11.0.21+9-post-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)
You only need the headless package — there is no GUI on a server, and pulling the full openjdk-11-jre drags in graphical libraries you'll never use.
Step 2: Install Jenkins
Add the official Jenkins LTS apt repository — the version in Ubuntu's archive is years out of date and unmaintained.
# Add the signing key
sudo wget -O /usr/share/keyrings/jenkins-keyring.asc \
https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key
# Add the repository (signed-by pins the key to this repo only — safer than legacy apt-key)
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
# Install
sudo apt update
sudo apt install -y jenkins
The package starts the service and enables it at boot. Verify:
sudo systemctl status jenkins
# ● jenkins.service - Jenkins Continuous Integration Server
# Loaded: loaded (/lib/systemd/system/jenkins.service; enabled; vendor preset: enabled)
# Active: active (running) since ...
Jenkins is now running on http://<server-ip>:8080. Don't open that port in your firewall yet — we're going to bind it to localhost in a moment so the only public way in is via Nginx on :443.
Step 3: Bind Jenkins to localhost
By default Jenkins listens on 0.0.0.0:8080, which means it accepts connections on every network interface — including the public one. Once Nginx is fronting it, that's a redundant attack surface. Bind it to 127.0.0.1 so only the local Nginx can reach it.
The bind address lives in the systemd drop-in config. Create an override:
sudo systemctl edit jenkins
That opens an editor on /etc/systemd/system/jenkins.service.d/override.conf. Add:
[Service]
Environment="JENKINS_LISTEN_ADDRESS=127.0.0.1"
Save and exit, then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart jenkins
Confirm the bind:
sudo ss -tlnp | grep 8080
# LISTEN 0 50 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=...))
The address column should show 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080. Now Jenkins is unreachable from the network — the only thing that can talk to it is Nginx running on the same box.
Step 4: Initial Jenkins setup (over SSH tunnel)
Jenkins requires a one-time unlock with an admin token before any web UI is usable. Since the port is now localhost-only, the cleanest way to do this is an SSH tunnel from your workstation:
# On your workstation
ssh -L 8080:127.0.0.1:8080 user@your-server
Then open http://localhost:8080 in your browser. You'll see "Unlock Jenkins" asking for a token from /var/lib/jenkins/secrets/initialAdminPassword. Get it on the server:
sudo cat /var/lib/jenkins/secrets/initialAdminPassword
Paste it into the form, click Install suggested plugins, and create your admin user. Set the Jenkins URL to https://jenkins.example.com/ — even though Nginx isn't ready yet, this is the URL Jenkins will use when generating links in emails, build notifications, and webhooks. You can change it later under Manage Jenkins → System → Jenkins URL.
Close the SSH tunnel when you're done.
Skipping the localhost bind and doing the initial setup over
:8080from the public internet is exactly the window an opportunistic scanner needs. Bind to localhost first, tunnel for setup. The whole window is five minutes if you do it in this order.
Step 5: Install Nginx
sudo apt install -y nginx
sudo systemctl enable --now nginx
Verify Nginx is up:
sudo systemctl status nginx
curl -I http://localhost
# HTTP/1.1 200 OK
# Server: nginx/1.18.0 (Ubuntu)
Open the firewall for HTTP and HTTPS:
sudo ufw allow 'Nginx Full' # opens 80 and 443
sudo ufw allow OpenSSH # don't lock yourself out
sudo ufw enable
sudo ufw status
If ufw isn't installed/enabled and you use a different firewall (cloud provider security group, iptables, nftables), open :80 and :443 from 0.0.0.0/0 there.
Step 6: Get a TLS certificate from Let's Encrypt
Use Certbot with the Nginx plugin — it edits your Nginx config in place and sets up automatic renewal as a systemd timer.
sudo apt install -y certbot python3-certbot-nginx
Before running Certbot, create a minimal Nginx server block for the domain so the HTTP-01 challenge has somewhere to land. Create /etc/nginx/sites-available/jenkins:
server {
listen 80;
listen [::]:80;
server_name jenkins.example.com;
# Certbot uses this to serve the HTTP-01 challenge response
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 404; # placeholder; we'll replace this after the cert is issued
}
}
Enable it and reload:
sudo ln -s /etc/nginx/sites-available/jenkins /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default # optional; removes the default landing page
sudo nginx -t
sudo systemctl reload nginx
Now request the certificate:
sudo certbot --nginx -d jenkins.example.com \
--non-interactive --agree-tos -m you@example.com --redirect
What the flags do:
--nginx— use the Nginx plugin (auto-detects the server block, edits it to use the new cert).-d jenkins.example.com— the domain. Add-d www.jenkins.example.cometc. for additional names.--redirect— adds a 301 from:80→:443. Strongly recommended.-m— contact email for expiry warnings.--non-interactive --agree-tos— script-friendly; for the first run you can omit these and answer the prompts interactively.
On success, Certbot prints something like:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/jenkins.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/jenkins.example.com/privkey.pem
This certificate expires on 2026-08-08.
Auto-renewal is already configured. Verify:
sudo systemctl list-timers | grep certbot
# certbot.timer — runs twice daily, attempts renewal if a cert is < 30 days from expiry
sudo certbot renew --dry-run # full simulation without actually renewing
You can cross-check expiry from outside the box with Xitoring's SSL certificate monitoring — it alerts before the cert expires regardless of whether the renewal job ran. Belt-and-suspenders is the right posture for cert renewal: if the cron job silently fails, the alert is your only signal before users see TLS errors.
Step 7: Write the reverse-proxy config
Now replace the placeholder server block with the real one. Open /etc/nginx/sites-available/jenkins and replace its contents with:
upstream jenkins {
keepalive 32; # keepalive connections to upstream
server 127.0.0.1:8080;
}
# HTTP → HTTPS redirect (Certbot --redirect already inserted this; included for completeness)
server {
listen 80;
listen [::]:80;
server_name jenkins.example.com;
return 301 https://$host$request_uri;
}
# HTTPS — terminate TLS, proxy to Jenkins
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name jenkins.example.com;
ssl_certificate /etc/letsencrypt/live/jenkins.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jenkins.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; # modern TLS defaults
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # written by Certbot
# Jenkins won't generate stylesheets/menus correctly without these
# being passed through. Set the docroot to the Jenkins war path.
root /var/cache/jenkins/war/;
# Logs (per-vhost — easier to grep than the shared access log)
access_log /var/log/nginx/jenkins.access.log;
error_log /var/log/nginx/jenkins.error.log;
# Tighten timeouts for long-running requests (build queue, log streaming)
proxy_read_timeout 90s;
proxy_send_timeout 90s;
# Allow large payloads (artifacts, file uploads in pipeline tools)
client_max_body_size 10m;
client_body_buffer_size 128k;
# Don't buffer responses — Jenkins streams build console output
proxy_buffering off;
proxy_request_buffering off;
location / {
# Pass on the original Host and scheme so Jenkins generates correct URLs
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_set_header X-Forwarded-Port $server_port;
# Required for keepalive to upstream (paired with the upstream { keepalive } directive)
proxy_http_version 1.1;
proxy_set_header Connection "";
# WebSocket upgrade (for inbound JNLP agents over the web socket transport,
# the build console live-tail, and the Blue Ocean UI)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# The proxy itself
proxy_pass http://jenkins;
proxy_redirect default;
}
}
The WebSocket upgrade above uses $connection_upgrade, which isn't a standard Nginx variable — it has to be defined via map. Add this to /etc/nginx/conf.d/connection_upgrade.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Test the config and reload:
sudo nginx -t
sudo systemctl reload nginx
Hit the URL from your workstation:
https://jenkins.example.com/
You should see the Jenkins login page over a valid TLS connection. The padlock should be green; the certificate should be the Let's Encrypt one.
Step 8: Tell Jenkins it's behind a reverse proxy
Jenkins doesn't auto-detect that it's behind a proxy. There are two settings to fix.
Set the Jenkins URL
Go to Manage Jenkins → System and set Jenkins URL to the full external URL: https://jenkins.example.com/ (with the trailing slash). This is the URL Jenkins uses for:
- Email notification links,
- GitHub/Bitbucket/GitLab webhook callback URLs,
- Inbound agent connection strings,
- Pipeline
BUILD_URLandJOB_URLvariables.
If this is wrong (e.g. still http://localhost:8080/), webhooks and emails will point at the wrong place even though the UI works fine over the proxy.
Clear the reverse-proxy warning
You may see a banner at the top of Manage Jenkins:
It appears that your reverse proxy setup is broken.
This means Jenkins compared the URL it sees in the request headers to the configured Jenkins URL and they didn't match. Almost always one of:
X-Forwarded-Protonot being passed. Jenkins thinks the request is HTTP because the proxy hasn't told it the original was HTTPS. Check thatproxy_set_header X-Forwarded-Proto $scheme;is in the Nginx config and Nginx has been reloaded.Hostheader not being passed. Jenkins comparesHostto the host part of the configured Jenkins URL. If Nginx is rewriting Host, set it back:proxy_set_header Host $host;.- Jenkins URL has a trailing-slash mismatch.
https://jenkins.example.com(no slash) vshttps://jenkins.example.com/(slash). Use the slash version.
After fixing, click the "Test reverse proxy setup" link in the Jenkins UI. The warning should disappear immediately. If it doesn't, check /var/log/nginx/jenkins.error.log and /var/log/jenkins/jenkins.log for hints.
Step 9: Verify the full chain
A short checklist that proves the setup is genuinely working, not just appears to:
# 1. TLS handshake from the public internet (use a third-party machine, not the server itself)
openssl s_client -connect jenkins.example.com:443 -servername jenkins.example.com < /dev/null 2>&1 \
| grep -E "subject=|issuer=|Verify return"
# 2. HTTP → HTTPS redirect works
curl -I http://jenkins.example.com
# HTTP/1.1 301 Moved Permanently
# Location: https://jenkins.example.com/
# 3. HTTPS responds with the Jenkins login page
curl -sI https://jenkins.example.com | head -n 3
# HTTP/2 200
# server: nginx/1.18.0 (Ubuntu)
# x-jenkins: 2.440.1
# ^ proves the response really came from Jenkins, not Nginx default page
# 4. Jenkins isn't reachable from the public IP on :8080 anymore
curl -I http://your-server-ip:8080
# curl: (7) Failed to connect ... Connection refused
# ^ if this returns 200, the bind to localhost didn't take
In the Jenkins UI:
- Manage Jenkins → System — no reverse-proxy warning.
- Manage Jenkins → System → Jenkins URL —
https://jenkins.example.com/. - Trigger a tiny job (a freestyle "echo hello" build) and confirm the live console output streams in real time, not in chunks. If output appears in 4 KB bursts,
proxy_buffering off;isn't taking effect — check that the location block actually overrides the default.
WebSockets, agents, and the bits that often break
Jenkins uses WebSockets for the live build-console, inbound agent connections (when configured to use the WebSocket transport), and Blue Ocean. WebSockets travel over the same :443 connection but require an HTTP/1.1 Upgrade handshake. The config above handles this via:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
If your build console freezes mid-build or doesn't update, the WebSocket isn't being upgraded. Open the browser dev tools → Network → WS tab and reload. You should see a 101 Switching Protocols. If you see a 200 or no WS connection at all, Nginx is handling the request as plain HTTP. The usual fix is:
- Confirm the
map $http_upgrade $connection_upgrade {...}block exists athttp {}scope (not inside theserver {}) —/etc/nginx/conf.d/*.confis loaded intohttp {}, so the file shown above works. - Confirm
proxy_http_version 1.1;is in the location block — the default 1.0 doesn't supportUpgrade.
For inbound JNLP agents over WebSocket, configure the agent (in Manage Jenkins → Nodes → <agent> → Configure) with "Use WebSocket" enabled. That tunnels the agent connection through :443 — no separate JENKINS_AGENT_PORT needed. This is the recommended modern setup; the older "JNLP4 over TCP" mode requires opening a separate port (typically :50000) through the firewall and is no longer the default.
CSRF protection and webhooks
Jenkins enables CSRF protection by default. Most webhook integrations (GitHub, GitLab, Bitbucket) handle this transparently — they fetch a crumb first or use the dedicated /github-webhook/-style endpoints that are exempt. But homegrown scripts that POST to Jenkins from outside need to either:
- Fetch a crumb from
https://jenkins.example.com/crumbIssuer/api/jsonand include it in the request header, or - Use an API token for that user instead of a session cookie — API tokens are explicitly exempt from CRSF for the same user.
Don't disable CSRF protection to "fix" a webhook integration. The right answer is almost always an API token.
Hosting Jenkins under a subpath (/jenkins)
If you want Jenkins to coexist with another service on the same domain (e.g. https://example.com/jenkins/ instead of jenkins.example.com), there are two changes:
-
Tell Jenkins about the prefix. Edit
/etc/default/jenkins(or the systemd override created in Step 3) and add:JENKINS_OPTS="--prefix=/jenkins"Or via systemd:
[Service] Environment="JENKINS_OPTS=--prefix=/jenkins"Reload and restart:
sudo systemctl daemon-reload sudo systemctl restart jenkins -
Update the Nginx location block to match:
location /jenkins/ { # ... same proxy_set_header lines as before ... proxy_pass http://jenkins; # NOTE: no trailing slash }The
--prefix=/jenkinsflag tells Jenkins to serve every path with/jenkinsprepended (including its CSS, JS, and form actions), so the proxy_pass target should not strip the prefix. -
Set the Jenkins URL to
https://example.com/jenkins/.
Done correctly, every Jenkins-generated link and resource includes the /jenkins prefix and the proxy passes everything through unchanged. Done incorrectly (e.g. proxy_pass http://jenkins/; with a trailing slash that strips the prefix), Jenkins generates URLs without the prefix and the page half-loads with broken CSS — the textbook symptom.
Hardening checklist
Once the basic setup works, harden it:
-
Disable HTTP entirely if you have no reason to keep
:80open beyond the Certbot HTTP-01 challenge. Certbot has a--standalonemode that opens:80only during renewal; otherwise, redirect-only is fine and is what--redirectproduces. -
Enable HSTS:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;Add inside the HTTPS server block. Don't enable HSTS until you're sure the certificate works — once a browser has the HSTS header, it refuses to load the site over HTTP for
max-ageseconds. -
Restrict access by IP if Jenkins should only be reachable from the office or a VPN:
location / { allow 203.0.113.0/24; # office allow 10.8.0.0/16; # VPN deny all; # ... existing proxy_set_header lines ... } -
Run the Jenkins security checklist in the UI: enable matrix-based authorization, disable signup, enable agent-to-master access control, audit installed plugins for known CVEs.
-
Monitor the service. Pair the TLS-cert monitoring above with HTTP uptime checks against
https://jenkins.example.com/login(a 200 there means Nginx, Jenkins, and the JVM are all alive), and a process check onjenkins.service. Xitoring's HTTPS monitoring and server monitoring with Xitogent cover both. A Jenkins outage during a release window is one of those incidents you want to learn about from a pager, not from your colleagues.
Troubleshooting
502 Bad Gateway from Nginx
Nginx is up but can't reach Jenkins. Check, in order:
sudo systemctl status jenkins— is Jenkins actually running? After a kernel update or out-of-memory event, it may have died silently.sudo ss -tlnp | grep 8080— is Jenkins still listening on127.0.0.1:8080? If it's listening on a different address, the Nginx upstream points at nothing.sudo journalctl -u jenkins -n 100— look for Java exceptions, OOM kills (Killed process ... java), or "address already in use".sudo tail -n 50 /var/log/nginx/jenkins.error.log— Nginx logs the upstream connection failure with the exact reason.
404 for every static resource (broken CSS, no images)
Jenkins is up but generating URLs without the proxy prefix, or the root directive in the Nginx config is wrong. Confirm the Jenkins URL matches what you're typing in the browser, and that the root /var/cache/jenkins/war/; line is present in the HTTPS server block.
"It appears that your reverse proxy setup is broken" persists
Almost always a missing X-Forwarded-Proto, missing Host, or trailing-slash mismatch in the Jenkins URL. See Step 8 above. Also check that you reloaded Nginx (sudo systemctl reload nginx) after editing the config — nginx -t validates syntax but doesn't reload.
Build console output appears in chunks instead of streaming
proxy_buffering off; isn't taking effect. Confirm it's in the location / block, not just the server block (some Nginx versions are picky about scope). Also check that no upstream proxy or CDN in front of Nginx is buffering — Cloudflare, for example, buffers responses by default.
TLS handshake fails with "unable to get local issuer certificate"
The full certificate chain (intermediate + leaf) isn't being served. Certbot's fullchain.pem includes the intermediate, so this is usually misconfiguration — verify the ssl_certificate directive points at fullchain.pem, not cert.pem. Test with:
openssl s_client -connect jenkins.example.com:443 -servername jenkins.example.com -showcerts < /dev/null
The output should show two certificates (leaf + intermediate). If only one appears, fix the ssl_certificate path and reload.
Certbot renewal fails
The renewal command Certbot runs is certbot renew. Run it by hand to see the actual error:
sudo certbot renew --dry-run
The most common cause on a server with Nginx is that the /.well-known/acme-challenge/ location was removed or shadowed when the production Nginx config was written. Certbot's Nginx plugin temporarily edits the config to expose that path, but if your custom config has location / before the plugin's insertion point, the rewrite can fail. Make sure the HTTP server block keeps the explicit /.well-known/acme-challenge/ location even after Certbot's first run.
Inbound agents can't connect
If you're using WebSocket transport (recommended), the agent JVM is dialing wss://jenkins.example.com/ and following the WebSocket upgrade. Verify by tailing the agent log — you should see "Connected" within a few seconds of starting the agent. If you see "Connection failed" or "HTTP 502", Nginx isn't completing the WebSocket upgrade — see the WebSockets section above.
If you're using TCP transport (legacy), Jenkins uses a separate port (defined in Manage Jenkins → Security → TCP port for inbound agents). That port needs to be open through the firewall and reachable from the agent. The reverse proxy doesn't help here — the agent connects directly to Jenkins on that port. Switch to WebSocket transport unless you have a specific reason not to.
Jenkins runs out of memory under load
Default JVM heap on the Ubuntu Jenkins package is small (often 256m–512m). For anything beyond a tiny install, set the heap size in the systemd override:
[Service]
Environment="JAVA_OPTS=-Xmx2g -Xms512m -XX:+UseG1GC"
reload-daemon and restart. Watch with:
sudo journalctl -u jenkins -f | grep -i "outofmemory\|killed"
Pair with a memory monitor — running out of heap during a release is the kind of incident a Xitoring resource alert catches before users notice the build queue stalling.
Summary
To run Jenkins behind Nginx with TLS on Ubuntu 20.04:
- Install Java 11 (or 17) —
openjdk-11-jre-headless. - Install Jenkins from the official LTS apt repo, not the Ubuntu archive.
- Bind Jenkins to
127.0.0.1:8080via a systemd drop-in. Public access goes through Nginx only. - Do the initial admin setup over an SSH tunnel so the unlock token never crosses the public internet in cleartext.
- Install Nginx and open
:80/:443inufw. - Get a Let's Encrypt cert with
certbot --nginx --redirect. Verify auto-renewal with--dry-run. - Write the reverse-proxy config with the right
X-Forwarded-*headers, WebSocket upgrade map,proxy_buffering off, and akeepaliveupstream block. - Set Jenkins URL in Manage Jenkins → System to the full HTTPS URL (with trailing slash) and clear the "reverse proxy is broken" warning.
- Verify end-to-end: TLS handshake works, the
x-jenkinsheader is present, console output streams live, agents reconnect on restart, and:8080is unreachable from the public IP. - Harden: HSTS, optional IP allowlist, certificate monitoring, uptime checks, JVM heap sizing.
The setup itself is mostly mechanical — install, bind, proxy, certificate. The parts people get wrong are the parts the docs gloss over: the X-Forwarded-Proto header that fixes the reverse-proxy warning, the map-defined $connection_upgrade variable that makes WebSockets work, the proxy_buffering off that makes the build console feel live, and the trailing-slash discipline in the Jenkins URL. Get those right and the rest is just a vhost config.