Skip to main content

Windows (WinRM) module

Deploy any file to any Windows path, then run PowerShell post-deploy scripts. The Windows sibling of the SSH module — same PathSet + ActionSet operator UX, different transport. Distinct from the IIS module, which is dedicated to IIS binding lifecycle.

Type: winrm Version: 1.0.0 Rollback: previous-version re-deploy

When to pick this module

Pick winrm when you want to:

  • Drop a PEM / PFX / DER / arbitrary file at a known Windows path (C:\inetpub\certs\site.pfx, \\fileserver\share\...).
  • Run PowerShell after — restart a service, import into a Java keystore via keytool, run a custom hook script.
  • Cover Windows targets that aren't IIS: Tomcat, JBoss, custom .NET services, ad-hoc certificate stores.

If your only goal is to update an IIS HTTPS binding, the dedicated IIS module handles binding discovery, app-pool recycling, and old-cert cleanup automatically.

Setup, step by step

Run each block on the target Windows host in an elevated PowerShell, then check the result before moving on. You don't need the big one-shot script — do it one step at a time.

Step 1 — Enable WinRM

Enable-PSRemoting -Force

Check the service is running:

Get-Service WinRM # Status should be "Running"

Step 2 — Pick your port (prefer HTTPS 5986)

See which listeners exist:

winrm enumerate winrm/config/Listener # look for Transport = HTTP (5985) / HTTPS (5986)

Recommended: use HTTPS on 5986. If there's no HTTPS listener, create one (needs a server cert whose name matches how CertAutoPilot will address the host):

# Server cert — DnsName MUST equal the host you'll enter in the CAP target (FQDN, or add the IP)
$cert = New-SelfSignedCertificate -DnsName "win01.corp.local" -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint -Force

Verify:

winrm enumerate winrm/config/Listener # now shows Transport = HTTPS, Port = 5986
Test-NetConnection -ComputerName localhost -Port 5986 # TcpTestSucceeded : True

You have AD CS? Enroll a Web Server / Server Authentication cert from your CA instead of self-signed — its root is already trusted, so CertAutoPilot won't need a TLS-trust override.

Plain HTTP 5985 also works (requires AllowUnencrypted on the service — see the warning under Authentication; the auth handshake is protected but the WSMan body is not), but it's a more common source of confusing 401 errors — if you're troubleshooting, move to 5986 first. See 401 - invalid content type.

Step 3 — Open the firewall

If Windows Firewall is on, open the port you chose:

New-NetFirewallRule -DisplayName "WinRM HTTPS 5986" -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow

If the firewall is off, skip this.

Step 4 — The account must be a local Administrator

The credential CertAutoPilot connects with must be in the local Administrators group: the default WinRM access control only admits Administrators, and the account must be able to write the PathSet paths and run whatever the ActionSet does (restart a service, update a keystore). This module never imports anything into the Windows certificate store — that is the IIS module's job.

net localgroup Administrators # the account (or its group) must be listed

Step 5 — Local admin over NTLM? Set one registry value

Only if the account is a local admin (not a domain account) using NTLM, UAC filters its token over the network. Fix it once:

New-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" `
-Name LocalAccountTokenFilterPolicy -Value 1 -PropertyType DWORD -Force

Domain accounts don't need this — they get their full admin token on a network logon.

Step 6 — Configure the target in CertAutoPilot

  • Use TLS: on (port 5986) — or off if you chose 5985.
  • Auth: NTLM (recommended). Only use Basic if you deliberately enabled it (Step 2 note) — Basic on plain HTTP needs AllowUnencrypted=true and sends credentials in cleartext.
  • Username: DOMAIN\user for a domain account, .\user for a local one (NTLM). For Basic, use the bare user (no prefix).
  • TLS verification: the listener certificate is verified against the CertAutoPilot host's trust store; there is no per-target CA field. With a self-signed 5986 cert either trust it on the CertAutoPilot host or set tls_skip_verify on the target (every deploy then logs a warning — the connection accepts any certificate).

Step 7 — Verify

Deploy a test distribution with the exact CAP account to confirm it connects and holds an administrator token. If a large-file transfer hits an envelope-size limit (WINRM_QUOTA), raise it once on the target: Set-Item WSMan:\localhost\MaxEnvelopeSizekb 8192.

Authentication methods

warning
Plain HTTP (5985) needs AllowUnencrypted — for every auth type

CertAutoPilot's WinRM clients authenticate the connection but do not encrypt the WSMan message body. Over HTTP 5985 the WinRM service only accepts such requests when AllowUnencrypted is enabled on the target — this applies to NTLM, Basic and Kerberos alike (a missing setting typically surfaces as an empty HTTP 500 after auth succeeds):

Set-Item WSMan:\localhost\Service\AllowUnencrypted $true
Restart-Service WinRM

Over HTTPS 5986 this is not needed (TLS protects the body) — prefer 5986 in production.

NTLM (default)

Works with local accounts and domain accounts. Enter just the account name: with the target's Domain field set it is sent as DOMAIN\user; with Domain empty it is sent as .\user (explicit local account). A name you qualify yourself (DOMAIN\user, user@fqdn, .\user) is used verbatim.

Basic

Username/password sent over the wire. Use only with TLS enabled — the target form warns when you mix Basic with plain HTTP.

Kerberos (first-class, zero-config)

Set auth_type=kerberos and the Domain field to the Kerberos realm (uppercase, e.g. CORP.LOCAL). Address the target by its AD FQDN (web01.corp.local) — never an IP: the SPN is auto-derived as HTTP/<hostname>, and the KDC has no service principal for a bare IP (KDC_ERR_S_PRINCIPAL_UNKNOWN).

That is the whole setup. There is no krb5.conf to create, mount, or point KRB5_CONFIG at — CertAutoPilot builds its Kerberos configuration in memory and discovers the domain controller automatically from the _kerberos._tcp.<REALM> DNS SRV record that every Active Directory domain publishes.

The remaining prerequisites are environmental, not product setup:

  1. DNS — the CertAutoPilot host must resolve AD DNS: the target's FQDN and the realm's SRV records. In a corporate network that is normally already true. If this host's DNS cannot resolve AD records, fill the optional KDC Address field on the target (host or host:port) to pin the domain controller explicitly — still a form field, never a file.
  2. FQDN targeting — connect by hostname, not IP (SPN requirement above).
  3. Time sync — Kerberos tolerates ±5 minutes of clock skew; any NTP-synced host qualifies.
  4. On plain HTTP (5985): AllowUnencrypted on the target — see the warning at the top of this section; Kerberos is not exempt. Not needed over HTTPS 5986.

Verify from the CertAutoPilot host if in doubt: getent hosts web01.corp.local and nc -z -w3 <dc> 88 both succeed.

Works against stock Windows KDCs

CertAutoPilot's Kerberos client explicitly disables PA-FX-FAST negotiation, which stock Windows KDCs do not answer — without that, a password login fails with KDC_ERR_PREAUTH_FAILED even though the password is correct. No action needed; this is handled for you.

NTLM disabled on hardened / domain-joined boxes

On a domain member locked down with Network security: Restrict NTLM: Incoming NTLM traffic (a common domain GPO), the WinRM listener rejects NTLM and every auth attempt 401s. Either allow incoming NTLM in the domain GPO (RestrictReceivingNTLMTraffic = 0 — a local registry override reverts on the next gpupdate) or switch to Kerberos (above), which needs no change on the box and no setup on the CertAutoPilot host. The IIS 401 guide has the full walkthrough.

CredSSP (multi-hop delegation) is not supported in this version. Most domain-joined scenarios are covered by Kerberos.

Worked before the upgrade, 401 now?

A bare username with an empty Domain used to be sent unqualified — a domain-joined server could resolve it as a domain account. Since 1.5.49 it is sent as .\user (explicitly local). If that was your setup, set Domain on the target or write the username as DOMAIN\user. The worker log records the assembled wire username at debug level.

PathSet — where files go

Target OS must be Windows

The PathSet (and ActionSet) you bind to a WinRM target must be created with target_kind: "windows". The form's PathSet/ActionSet dropdowns filter to Windows-scoped resources only; the backend rejects mismatched bindings at create/update time. Reusing a Linux PathSet on a WinRM target (or vice versa) is not allowed — POSIX paths and owner/mode have no Windows equivalent.

Identical shape to the SSH module's PathSet. Each entry pairs a content source with an absolute Windows path. Sources:

  • cert — leaf certificate PEM
  • chain — chain (intermediates) PEM
  • fullchain — leaf + chain PEM
  • private_key — private key PEM
  • combined — leaf + chain + key in one PEM file
  • pfx — PKCS#12 bundle (leaf + chain + key), protected with the secret project variable named in passphrase_variable

Format defaults to pem; der is supported for cert and private_key sources (pfx takes no format).

Path validation rejects: relative paths, .. traversal segments (a .. inside a name such as a..pem is fine), null bytes, Windows reserved device names (CON, NUL, PRN, AUX, COM1–9, LPT1–9), forbidden filename characters (< > : " | ? *), components with trailing dots or spaces (Windows strips them silently), and paths longer than 252 characters. Drive-letter and UNC paths are both accepted.

The length limit is Windows' own 259-character maximum minus the seven characters of the staging suffix the transfer appends: the file is written beside its destination and moved into place only after the content check, so the staging name has to fit too. A longer path is refused before anything is sent.

ActionSet — what runs after

By default the ActionSet runs only when a deployed file actually changes (hash-based skip), so restart/reload actions stay idempotent on no-op redistributes. Set run_always on the ActionSet to run it on every distribution even when nothing changed — for verification/diagnostic actions that must run each time (e.g. printing the deployed certificate). When it runs despite no change the log shows no file changed, running anyway (run_always enabled).

Two modes:

command mode

List of PowerShell commands. Run one-by-one, each as its own powershell.exe -EncodedCommand process. Optional allowed_commands regex allowlist (defence in depth — operator mistypes a destructive command and the regex blocks it): each command is matched after variable substitution, patterns are unanchored (use ^…$), and each must be a valid regex of at most 512 characters — refused at save. If a stored pattern still cannot be used at deploy, the target fails with WINRM_VALIDATION and no command runs; the list never falls back to "allow everything". A command outside the list also fails the target with WINRM_VALIDATION.

script_inline mode

Multi-line script body. Uploaded to a remote temp path ($env:TEMP\certautopilot-XXXX.ps1) via the file transport, ACL-restricted to the executing user, run with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File, then removed (best-effort cleanup).

Operator picks the shell: powershell.exe (Windows PowerShell 5.1, default — present on every modern Windows Server) or pwsh.exe (PowerShell 7+, must be installed on the target).

Variable substitution

Project variables expand via the ${{ NAME }} placeholder syntax. Values are PowerShell-escape-aware: a hostile project variable value $(Get-Process) is substituted as the literal string '$(Get-Process)' rather than an executable sub-expression. Same threat-model fix as the SSH module's shell-escape work; consult project variables for syntax details and escape semantics.

Worked example: cert + Tomcat reload

PathSet:

  • C:\Tomcat\conf\ssl\fullchain.pem → source fullchain
  • C:\Tomcat\conf\ssl\privkey.pem → source private_key

ActionSet (mode script_inline, shell powershell.exe; KEYSTORE_PASS is a secret project variable — substituted values arrive already single-quoted, so do not add quotes around the placeholder). keytool cannot read PEM directly, so the PEM pair is converted to PKCS#12 first, and both tools take the password from an environment variable rather than a command-line argument:

$ErrorActionPreference = 'Stop'
$env:KS_PASS = ${{ KEYSTORE_PASS }}

# PEM → PKCS#12 (password read from the environment, never on the command line)
& openssl pkcs12 -export `
-in "C:\Tomcat\conf\ssl\fullchain.pem" `
-inkey "C:\Tomcat\conf\ssl\privkey.pem" `
-name tomcat `
-out "C:\Tomcat\conf\ssl\keystore.p12" `
-passout env:KS_PASS

# PKCS#12 → JKS for Tomcat
& keytool -importkeystore -noprompt `
-srckeystore "C:\Tomcat\conf\ssl\keystore.p12" -srcstoretype PKCS12 -srcstorepass:env KS_PASS `
-destkeystore "C:\Tomcat\conf\ssl\keystore.jks" -deststoretype JKS -deststorepass:env KS_PASS

Restart-Service -Name Tomcat9
Write-Output "tomcat reloaded with new cert"

(Or point Tomcat's connector at the .p12 directly and skip the JKS step.) Secret values are masked in the job log, but the rendered script runs on the host — keep the temp directory private.

Rollback

Supported via previous-version re-deploy: a rollback re-writes a previous retained certificate version's files via the PathSet and re-runs the ActionSet — the module's normal deploy path, fed older material. Whatever the ActionSet does (restart a service, import into a JKS, custom logic) simply runs again against the older files. Eligibility, the version picker, and auto-rollback: Rollback.

Limits + performance

KnobDefaultNote
File transfer rate~60–300 KB/secWAN-RTT bound; the payload streams in on the command's standard input in a single round trip and is content-checked on the host.
Per-file size cap100 MBConfigurable on the target via max_file_size_bytes. Raise only if you genuinely need to push > 100 MB.
Inline script sizeno capThe script body is uploaded to the host as a file and run with -File.
Command-mode entry size~12,000 characters (approx.)Each command is sent base64-encoded on a Windows command line, which is limited to 32,767 characters; UTF-16 + base64 leaves roughly 12,000 characters of PowerShell. Put longer logic in a script_inline ActionSet.
Per-target concurrency10Module-level concurrency, clamped to 1–50; forced to 1 when Parallel Execution is off in Settings → General.
Per-target timeout5 mintarget_timeout_seconds on the module config.
Per-command timeout60 seccommand_timeout_seconds on the target caps a whole script_inline run (and the health check / dry-run probe); it does not cap command-mode entries. ActionSet timeout_seconds (default: no limit) applies per command in command mode and to the whole script in script_inline mode.
Output truncation16 KBPer-command stdout+stderr; PowerShell verbosity bounded.

Error codes

The module emits structured WINRM_* error codes for retry classification:

  • WINRM_CONNECT — dial / DNS / connection refused or reset (network class — retried)
  • WINRM_AUTH — 401, NTLM/Kerberos rejection, "Access denied", and TLS handshake / certificate errors on the listener (auth class — not retried)
  • WINRM_TIMEOUT — a deadline expired or the run was cancelled (transient — retried)
  • WINRM_EXEC — non-zero PowerShell exit without a recognised cause (transient — retried)
  • WINRM_PS_SYNTAX — PowerShell parse error or "is not recognized" in the error text or the failed ActionSet output (validation — not retried)
  • WINRM_FILE — a file could not be written on the host: parent directory, transfer, content-check mismatch, or "access is denied" on the destination (transient — retried)
  • WINRM_VALIDATION — operator configuration error: unresolved PathSet/ActionSet, invalid path or missing variable, missing/non-secret pfx passphrase variable, invalid or absent shell, a command rejected by allowed_commands or an unusable pattern (validation — not retried)
  • WINRM_QUOTA — MaxEnvelopeSize exceeded, or a file over max_file_size_bytes (permanent — operator must raise the quota)

A message that starts with one of these codes (for example WINRM_VALIDATION: file path must be absolute) is recorded under that code. WINRM_CONNECT/WINRM_EXEC are generic wrappers, so a 401 or TLS error inside them is still recorded as WINRM_AUTH.

vs. the IIS module

Aspectwinrmiis
ScopeGeneric file deploy + scriptIIS binding lifecycle
PathSet / ActionSetYesNo (uses fixed IIS scripts)
ConcurrencyParallel (10 default, clamped 1–50; 1 when Parallel Execution is off)Sequential
AuthNTLM, Basic, KerberosNTLM, Basic, Kerberos
RollbackYes (previous-version re-deploy)Yes (previous-version re-deploy)
Picks IIS bindings automaticallyNo (operator scripts it)Yes

Multi-certificate hosts

Two mechanisms let one WinRM target serve many certificates:

  1. Per-distribution PathSet/ActionSet — each certificate's distribution can select its own PathSet (where files land) and ActionSet (what runs afterwards) from the certificate's Distributions → Overrides drawer (winrm { path_set_id, action_set_id }, with a * default row). A selected override must resolve — a deleted or wrong-kind resource fails the run instead of silently using the target's own libraries.
  2. Certificate-context variables in paths — PathSet paths may contain ${{ CAP_PRIMARY_DOMAIN }}, ${{ CAP_DOMAIN_SLUG }}, ${{ CAP_CERT_TOKEN }} or ${{ CAP_DEPLOY_NAME }}, rendered per certificate at distribution time: C:\\certs\\${{ CAP_DOMAIN_SLUG }}\\bundle.pfx. Use CAP_DOMAIN_SLUG, not CAP_PRIMARY_DOMAIN, in Windows paths: * is invalid in Windows file names, so a wildcard certificate's primary domain (*.example.com) always fails, while the slug renders as example-com. Project variables also render; secret variables are rejected in paths; a missing variable fails that target. Note that substitution always applies: a literal ${{ ... }} in a path or command must be escaped with a backslash (\${{ ... }}). (A rollback re-deploys the previous version through the same PathSet rendering.)

When several certificates share a host, put ${{ CAP_CERT_TOKEN }} or ${{ CAP_DEPLOY_NAME }} in the path (or use distinct PathSets) so files never collide.