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
AllowUnencryptedon 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 confusing401errors — 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 useBasicif you deliberately enabled it (Step 2 note) — Basic on plain HTTP needsAllowUnencrypted=trueand sends credentials in cleartext. - Username:
DOMAIN\userfor a domain account,.\userfor a local one (NTLM). For Basic, use the bareuser(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_verifyon 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
AllowUnencrypted — for every auth typeCertAutoPilot'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:
- 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 (
hostorhost:port) to pin the domain controller explicitly — still a form field, never a file. - FQDN targeting — connect by hostname, not IP (SPN requirement above).
- Time sync — Kerberos tolerates ±5 minutes of clock skew; any NTP-synced host qualifies.
- On plain HTTP (5985):
AllowUnencryptedon 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.
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.
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
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 PEMchain— chain (intermediates) PEMfullchain— leaf + chain PEMprivate_key— private key PEMcombined— leaf + chain + key in one PEM filepfx— PKCS#12 bundle (leaf + chain + key), protected with the secret project variable named inpassphrase_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→ sourcefullchainC:\Tomcat\conf\ssl\privkey.pem→ sourceprivate_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
| Knob | Default | Note |
|---|---|---|
| File transfer rate | ~60–300 KB/sec | WAN-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 cap | 100 MB | Configurable on the target via max_file_size_bytes. Raise only if you genuinely need to push > 100 MB. |
| Inline script size | no cap | The 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 concurrency | 10 | Module-level concurrency, clamped to 1–50; forced to 1 when Parallel Execution is off in Settings → General. |
| Per-target timeout | 5 min | target_timeout_seconds on the module config. |
| Per-command timeout | 60 sec | command_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 truncation | 16 KB | Per-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 byallowed_commandsor an unusable pattern (validation — not retried)WINRM_QUOTA— MaxEnvelopeSize exceeded, or a file overmax_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
| Aspect | winrm | iis |
|---|---|---|
| Scope | Generic file deploy + script | IIS binding lifecycle |
| PathSet / ActionSet | Yes | No (uses fixed IIS scripts) |
| Concurrency | Parallel (10 default, clamped 1–50; 1 when Parallel Execution is off) | Sequential |
| Auth | NTLM, Basic, Kerberos | NTLM, Basic, Kerberos |
| Rollback | Yes (previous-version re-deploy) | Yes (previous-version re-deploy) |
| Picks IIS bindings automatically | No (operator scripts it) | Yes |
Multi-certificate hosts
Two mechanisms let one WinRM target serve many certificates:
- 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. - 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. UseCAP_DOMAIN_SLUG, notCAP_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 asexample-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.