jenkins

A Windows agent for Jenkins, from bare VM to online

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.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev