guest@6admin.io:~$

Nginx on Windows in front of Jenkins: the proxy, the logs and the upgrades

Standing up an nginx on Windows Server to terminate TLS in front of a Jenkins takes five minutes for the version that works in a demo and a real half-day for the version you stand behind in production. The reverse proxy part is not the hard one, it fits in a well-tuned location. What makes the difference is what Windows does not give you and no tutorial mentions: log rotation and an upgrade that actually takes. One word before we start: if the same machine also fronts a Perforce, do not try to run it through the config below. The P4 protocol is not HTTP, it calls for an entirely different approach I cover separately in nginx in front of Perforce .

Jenkins: the UI and the agents in a single location

Jenkins is HTTP, so it lives in the http block as a real reverse proxy. Its subtlety is elsewhere: the web UI and the modern agents, which connect to the controller over WebSocket, come through the same entry point and the config has to serve both without splitting them into two location blocks. The key is a map that computes the Connection header depending on whether the request asks for a WebSocket upgrade or not and that you apply to all traffic:

http {
    upstream jenkins {
        server jenkins.domain.internal:8080;
        keepalive 16;
    }

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

You cannot hardcode Connection "upgrade", on pain of breaking the ordinary HTTP requests: the map returns upgrade when the client asks for a WebSocket upgrade and close otherwise, so the same location serves the UI and the agents.

    server {
        listen 443 ssl;
        http2 on;
        server_name jenkins.example.com;

        ssl_certificate     ssl/jenkins.example.com.crt;
        ssl_certificate_key ssl/jenkins.example.com.key;
        ssl_protocols       TLSv1.2 TLSv1.3;
        ssl_ciphers         HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers on;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options nosniff always;

        # Jenkins emits headers nginx considers invalid by default
        ignore_invalid_headers off;

        location / {
            proxy_pass http://jenkins;
            proxy_http_version 1.1;
            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;

            # WebSocket upgrade for the agents, driven by the map
            proxy_set_header Upgrade    $http_upgrade;
            proxy_set_header Connection $connection_upgrade;

            # no buffering: Jenkins CLI and artifact upload compatibility
            proxy_request_buffering off;
            proxy_buffering off;
            client_max_body_size 10m;

            proxy_connect_timeout 90s;
            proxy_send_timeout 90s;
            proxy_read_timeout 90s;
        }
    }
}

Three points are worth pausing on. ignore_invalid_headers off is needed because Jenkins emits headers nginx otherwise rejects and without this line some UI pages render broken. The two proxy_*_buffering off serve the Jenkins CLI and artifact transfers, which cope badly with nginx buffering. And proxy_read_timeout must stay larger than the WebSocket agents’ ping interval, otherwise an agent session gets cut silently: 90 seconds is enough because the agent pings far more often, but it is the first parameter to look at if agents drop intermittently under load. One convenience location, optional, when the UI serves a lot of static content:

        location ~ "^/static/[0-9a-fA-F]{8}/(.*)$" {
            rewrite "^/static/[0-9a-fA-F]{8}/(.*)" /$1 last;
        }

And a word on http2 on;: the old listen 443 ssl http2; spelling is deprecated in recent nginx in favour of the separate directive, you may as well take it right away.

The certificate, on Windows

certbot dropped Windows support early in 2024 and win-acme has been unmaintained since mid-2024, so the reasonable choice today is simple-acme, the successor by the same author, which installs through Chocolatey and handles the challenge and the renewal. nginx does not reconfigure itself the way it does with certbot’s nginx plugin, so you point nginx at the files the tool drops and reload nginx on renewal.

The two nuisances nobody writes about

Here is what is missing from most guides and it is precisely what makes the difference between a demo and a service.

Log rotation, first. On Linux you have logrotate, on Windows you have no equivalent and nginx keeps its log files open, so it is not enough to rename or delete them, nginx would keep writing to the old descriptor or, worse, stop writing. The correct pattern is a scheduled task that moves the log files then sends nginx the reopen signal:

# daily scheduled task
$log = "C:\nginx\logs"
$stamp = Get-Date -Format "yyyyMMdd"
Move-Item "$log\access.log" "$log\access-$stamp.log"
Move-Item "$log\error.log"  "$log\error-$stamp.log"
& C:\nginx\nginx.exe -s reopen          # nginx reopens its log files
Get-ChildItem "$log\*-*.log" | Where-Object LastWriteTime -lt (Get-Date).AddDays(-30) | Remove-Item

The nginx -s reopen is the key point, it is what makes nginx reopen fresh descriptors on the original names and without it the rotation does not run, it breaks logging.

The Chocolatey upgrade, next, is the most insidious trap. Chocolatey’s nginx package installs into a versioned folder, something like C:\tools\nginx-1.29.0 and an upgrade lays a new versioned folder down beside it, which means the Windows service, the config and the certificate paths still point at the old version while the new one sits unused. You think you have upgraded, you are still running on the old binary. Two clean ways out. Either you pin a stable install path at install time, which the package accepts as a parameter, so the version is never in the path. Or you point the service at a stable junction and re-point it in post-install after each upgrade:

# a stable path, re-pointed after each choco upgrade
$ver = (Get-ChildItem C:\tools\nginx-* | Sort-Object Name -Descending | Select-Object -First 1).FullName
if (Test-Path C:\nginx) { (Get-Item C:\nginx).Delete() }
New-Item -ItemType Junction -Path C:\nginx -Target $ver
Restart-Service nginx

The service always targets C:\nginx, the junction follows the latest installed version and the config and the certificates live outside the versioned folder so they are not swept away by the version rotation.

The proxy holds and so does the rest

The Jenkins reverse proxy fits in an upgrade map on a single location, ignore_invalid_headers off and buffering switched off and once those three reflexes are learned it never moves again. The real work is elsewhere, in the two operational obvious facts that are only obvious on Linux: log rotation by hand with nginx -s reopen and the Chocolatey upgrade that leaves you running on the old binary until you freeze a stable path. The demo version ignores all of this and looks like it works, until the day an agent drops, the disk fills with logs, or an nginx CVE stays in place because the upgrade never took.

[edit: 2026] The Ansible implementation of all this: the nginx role .