jenkins
A Windows agent for Jenkins, from bare VM to online

A client's product builds on Windows. Not "we have a Windows developer" — signed installers, a .NET Framework service, and a validation step that pokes at the certificate store. None of that is going to happen in a container on the Linux controller, and pretending otherwise costs a week.
So: a Windows agent. This is the boring half — the VMs, the JDK, the service wrapper, the plugin list. Next weekend I will write about the job that runs on it. If you want the short version, it is that the agent should dial the controller and not the other way round, and that almost every tutorial you find is describing a launch method that no longer exists.
Two machines, and why not one
The controller is Ubuntu 20.04 with Jenkins 2.319.2 LTS on Temurin 11. It went out on the twelfth, three days ago, and is a security release on the 2.319 line that started on the first of December. Java 8 still works at this version and I would not start there; 17 is not supported yet.
The agent is Windows Server 2019, four vCPU, 8 GB, 120 GB thin-provisioned, on vSphere 7.0 Update 2. Update 3 was pulled in November and I was not eager to be the person who found out why.
You can run the controller on Windows. Then every plugin bug that only reproduces on Windows becomes your bug, and the answer to "is this us or is this Jenkins" gets a lot more expensive. The controller should do nothing except schedule work and hold the config. Give it two vCPU and forget it exists.
The agent came off a template with a sysprep answer file, because I rebuilt it three times before I was happy and the second rebuild is where you find out which of your steps you did not write down.
How the agent connects
There are four ways to attach a Windows machine, and three of them are traps.
Java Web Start. Gone. Web Start was removed from Java 11, and by 2.309 the
-jnlpUrl argument to the agent jar was deprecated too. If a guide tells you
to open the agent's page in a browser on the Windows box and click Launch,
it was written before September 2021.
The WMI Windows Agents plugin. The controller reaches out over DCOM, copies the jar across and registers a service for you. It needs domain administrator credentials, DCOM open between the two machines, and a tolerance for error messages that are HRESULTs. It has been effectively unmaintained for years. Do not.
SSH. Win32-OpenSSH is genuinely fine now and the SSH Build Agents plugin
will use it. Then you are debugging quoting through two shells and deciding
whether the default shell should be cmd or PowerShell. Possible. Not first.
Inbound, over WebSocket. The Windows box runs agent.jar and dials the
controller on the same port the UI is on. No inbound port on the controller,
no firewall exception, and it survives a reverse proxy. WebSocket transport
has been in core since 2.217 and by 2.319 it is unremarkable.
That last one is what I use. The only thing it asks of you is that whatever sits in front of Jenkins passes the upgrade through:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_read_timeout 180s;
}
map $http_upgrade $http_connection {
default upgrade;
'' close;
}
Miss the map and the agent connects, works for exactly sixty seconds, and
drops. Twice.
The plugins
The default suggested set installs about eighty things. I took the pipeline core and added five.
PowerShell. Gives you the powershell step, on top of Durable Task. This
is the whole point of the exercise. Depending on your version it also gives
you a pwsh step for PowerShell 7 — check yours, because if it does not, you
will be shelling out through bat and you should know that now.
Credentials Binding. Secrets into environment variables, scoped to a block, masked in the log. Not optional.
Pipeline Utility Steps. readJSON, writeJSON, readYaml. The agent
will be handing structured data back to Groovy and I would rather not write a
parser in a Jenkinsfile.
Timestamper. Every line in the console gets a clock. On a nightly job this is the difference between "it hung" and "it hung in the certificate scan".
Lockable Resources. One Windows box, several jobs that want the whole machine. Cheaper than a second agent, for now.
Fix the versions in a plugins list and install them with the CLI rather than clicking. You will rebuild this controller.
The agent VM
JDK. Eclipse Temurin 11, MSI, all users. AdoptOpenJDK became Temurin during 2021 and every download URL I had bookmarked is dead; if you script this, script it against the Adoptium API rather than a static link.
Directory layout. Everything lives under C:\j. Not
C:\Program Files\Jenkins, not C:\Users\svc_jenkins\jenkins. Windows
projects hit MAX_PATH for real, and the fourteen characters you save on the
agent root are fourteen characters of build path you get back. While you are
there:
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
-Name LongPathsEnabled -Value 1 -Type DWord
git config --system core.longpaths true
Both, not either. The registry key only helps callers that opted into long path awareness in their manifest, and plenty of build tooling has not.
The service account. A local account, svc_jenkins, granted Log on as a
service and nothing else that it does not need. Not LOCAL SYSTEM. SYSTEM is
convenient for about a day, and then you notice that everything a build can do
is something a build can do to the machine, and that half your tooling wants a
user profile.
That profile is the one surprise here. A service logon does get a profile, but
it is the service account's, so Install-Module -Scope CurrentUser lands in a
Documents folder nobody will ever open. Decide once: every module goes in with
-Scope AllUsers, installed by you, pinned to a version, listed in a file in
the repo.
Making it a service
WinSW 2.9.0, renamed to agent-service.exe, with agent-service.xml next to
it:
<service>
<id>jenkins-agent</id>
<name>Jenkins Agent</name>
<description>Inbound Jenkins agent for win-build-01</description>
<executable>C:\Program Files\Eclipse Adoptium\jdk-11\bin\java.exe</executable>
<arguments>
-Xmx512m
-Dfile.encoding=UTF-8
-jar C:\j\agent.jar
-url https://ci.example.internal/
-name win-build-01
-secret @C:\j\secret.key
-webSocket
-workDir C:\j\a
</arguments>
<workingdirectory>C:\j</workingdirectory>
<logpath>C:\j\logs</logpath>
<log mode="roll-by-size">
<sizeThreshold>10240</sizeThreshold>
<keepFiles>8</keepFiles>
</log>
<onfailure action="restart" delay="10 sec"/>
<onfailure action="restart" delay="60 sec"/>
<serviceaccount>
<domain>.</domain>
<username>svc_jenkins</username>
<allowservicelogon>true</allowservicelogon>
</serviceaccount>
</service>
Two details in there matter more than they look.
-secret @C:\j\secret.key reads the secret from a file instead of putting it
on the command line, where any local user can read it out of the process list.
ACL that file to the service account and administrators, and nobody else.
-Dfile.encoding=UTF-8 on the agent JVM saves you one whole category of
confusing log corruption later.
Install and start it:
C:\j\agent-service.exe install
Start-Service jenkins-agent
If it starts and stops immediately, the answer is in C:\j\logs, and it is
almost always the secret, the URL, or the account not having the logon right.
PowerShell, both of them
The box has two, and being casual about which one runs is how you get a script that works on your laptop and not at two in the morning.
Windows PowerShell 5.1 is powershell.exe. It ships with the OS, runs on
.NET Framework, and is not getting new features ever again. It is also on
every Windows machine you will ever be handed, which is not nothing.
PowerShell 7.2 is pwsh.exe, installs side by side into
C:\Program Files\PowerShell\7, runs on .NET 6, went GA on the fifth of
November and is the current LTS. Install it from the MSI, and leave the
remoting option alone unless you have decided you want it.
Two settings on the agent, once:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine
RemoteSigned, not Bypass. The Durable Task shim writes a temporary .ps1
into the workspace and runs it; that is a local file, so RemoteSigned is
happy. Bypass buys you nothing here except a finding in the next audit.
And the encoding, which is the one that will actually bite you. Windows
PowerShell 5.1 writes UTF-16 from Out-File by default and takes its console
encoding from the machine's code page, which on a Bulgarian-locale server is
not 65001. Logs come back with question marks and a JSON file you hand to
another tool fails to parse for reasons that look like a bug in the other
tool. pwsh 7 defaults to UTF-8 without a BOM, which is one more reason to
prefer it for anything that produces a file.
Wiring up the node
Manage Jenkins → Nodes → New Node → Permanent Agent.
Remote root C:\j\a. Labels windows powershell5 powershell7 signing.
Usage set to Only build jobs with label expressions matching this node —
otherwise a Linux job with no agent label will cheerfully land on your
Windows box and fail in a way that takes ten minutes to understand.
Two executors, not four. This machine builds things that touch a shared cert store and a shared MSBuild cache, and the third concurrent build is where those stop being shared politely.
Save, copy the secret into C:\j\secret.key, start the service, and the node
goes green.
The smoke test
Before anything real, one pipeline whose entire job is to tell you what you are standing on:
pipeline {
agent { label 'windows' }
options { timestamps() }
stages {
stage('Who and what') {
steps {
powershell '''
$ErrorActionPreference = 'Stop'
$PSVersionTable | Format-List
whoami
[Console]::OutputEncoding.WebName
[Net.ServicePointManager]::SecurityProtocol
"workspace path length: $((Get-Location).Path.Length)"
'''
}
}
}
}
The line I care about is the fourth. On a fresh Windows Server 2019 with .NET
Framework 4.x, SecurityProtocol comes back as Ssl3, Tls, and every
Invoke-WebRequest against anything modern dies with The underlying
connection was closed, which tells you nothing. The fix belongs on the
machine, not at the top of every script:
$paths = @(
'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319'
)
foreach ($path in $paths) {
Set-ItemProperty -Path $path -Name SchUseStrongCrypto -Value 1 -Type DWord
Set-ItemProperty -Path $path -Name SystemDefaultTlsVersions -Value 1 -Type DWord
}
Both hives. The 32-bit one is not optional, because some of your tooling is 32-bit and you do not know which.
Three things I would do earlier
Build the image from a template on day one. I did it on day nine, after the third manual rebuild. Everything above is about forty lines of PowerShell and I typed most of it twice.
Ask for the antivirus exclusion immediately. Defender real-time scanning
over a package restore was roughly forty per cent of the wall time on this
box. Getting C:\j excluded took two days of asking and one afternoon of
measuring to make the case.
Build two agents, not one. A single agent is a single point of failure that you discover during a release, at which point nobody is interested in your explanation of why it was fine last week.
Next weekend: the first thing that actually runs on it, which is not a build.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


