Skip to main content

Microsoft IIS module

Deploys certificates to Windows Server IIS over WinRM: imports the PKCS#12 into the local machine certificate store and updates the site's HTTPS binding(s) — one binding, all matching bindings, or an explicit host-header list. Works with Windows Server 2016+.

Overview

  • Transport: WinRM (HTTP 5985 or HTTPS 5986).
  • Auth: NTLM (default), Basic, or Kerberos — set on the target (aligned with WinRM/Exchange); the credential carries only username + password. (Credentials created before 1.5.49 may still carry auth fields — they are honored when the target sets no auth type.) Over plain HTTP 5985 every auth type additionally needs AllowUnencrypted=true on the target machine (the client does not encrypt the WSMan body); over HTTPS 5986 it is not needed.
  • Artifact: PKCS#12 bundle (cert + private key + chain) generated on the fly, imported into Cert:\LocalMachine\<store>.
  • Binding: updates the IIS site's HTTPS binding to reference the new thumbprint; the old certificate is left in the store unless Remove old cert after bind is on.
  • Rollback: supported — a rollback re-imports a previous retained certificate version's PFX into the store and repoints the bindings (Rollback).

Prerequisites

Missing any of these produces a WinRM error at deploy time — see Troubleshooting for the exact symptom each one causes.

  • WinRM enabled on the target: Enable-PSRemoting -Force. Reachable on 5985 (HTTP) or 5986 (HTTPS) from the backend, with a firewall rule opening that port. For HTTPS WinRM the listener needs a valid server cert bound (winrm quickconfig -transport:https, or create the listener against an existing cert's thumbprint).

  • WebAdministration PowerShell module — the module runs Import-Module WebAdministration on the target. It ships with the IIS Management Scripts and Tools feature: Install-WindowsFeature Web-Scripting-Tools. Without it you get IIS_MODULE_MISSING. (This is not the "Management Service"/WMSvc — the module uses WinRM + PowerShell, not the IIS remote-management extension.)

  • A local Administrator account for the credential. WinRM's default authorization (RootSDDL) admits only Administrators and Remote Management Users — an account in neither is rejected with "Access is denied" (surfaced as HTTP 401). And even once connected, the module's operations — Import-PfxCertificate into Cert:\LocalMachine\My and the IIS binding update — require Administrator (Remote Management Users lets you connect but the cert import then fails). A constrained JEA endpoint also works if the module's cmdlets are allowlisted.

  • LocalAccountTokenFilterPolicy = 1 — required when the credential is a local admin account authenticating over NTLM (the common case). Without it, UAC filters the admin token on a network logon so the connection is NOT treated as an administrator, and WinRM denies the operation with "Access is denied" → the module reports a 401 - invalid content type. Set it once:

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

    (No effect for the built-in Administrator or domain accounts; needed for other local admins over NTLM.)

  • Auth method + username format must match the target's WinRM config:

    • NTLM (default, recommended): username .\user for a local account, or DOMAIN\user. No server-side auth changes needed on the default WinRM configuration. Note that the client authenticates with NTLM but does not encrypt the WSMan message body, so over plain HTTP 5985 the service needs AllowUnencrypted="true" (see Troubleshooting); prefer HTTPS 5986 in production.
    • Basic: username is the bare user (no .\ — Basic cannot parse the .\ prefix). Requires Basic="true" on the WinRM service, plus AllowUnencrypted="true" for HTTP or a working HTTPS 5986 listener. Use Basic if an NTLM PFX transfer fails on large files (see Troubleshooting).
  • IIS installed with at least one HTTPS binding on the target site (or use an apply mode that manages bindings machine-wide).

Setup, step by step

The IIS module connects over WinRM, so the transport setup is the same as the WinRM module — step by step. Do those 6 steps first (enable WinRM → prefer HTTPS 5986 → firewall → local admin → LATFP if a local admin over NTLM → CAP target auth = NTLM DOMAIN\user), then add the two IIS-specific ones below. Run each on the target IIS server, elevated, and check the result before moving on.

Step A — Install the IIS scripting tools

The module runs Import-Module WebAdministration on the target; without it you get IIS_MODULE_MISSING.

Install-WindowsFeature Web-Scripting-Tools

Check:

Get-Module -ListAvailable WebAdministration # should list the module

Step B — Confirm the site has an HTTPS binding

single / all_matching modes update an existing HTTPS binding — they never create one.

Import-Module WebAdministration
Get-WebBinding | Where-Object protocol -eq 'https' # at least one row for the target site

If there's none, add one in IIS Manager (or with New-WebBinding) before deploying.

Step C — Create the credential + target in CertAutoPilot

Then follow Create the module credential and Create an IIS target below.

Create the module credential

  1. Settings → Distribution → CredentialsNew.
  2. Module type: IIS (WinRM).
  3. Username and password — just the account name, no DOMAIN\\ prefix (the domain/realm comes from the target; a prefixed name is used as-is).
  4. Save. The auth method itself (NTLM / Basic / Kerberos + Realm / KDC Address) is chosen on the target, not here.

Create an IIS target

  1. Settings → Distribution → TargetsNew. Module: IIS.
  2. Fields:
    • Hostname — reachable from the backend.
    • Port — defaults to 5986 (HTTPS) / 5985 (HTTP) based on the Use TLS toggle.
    • Use TLS — on for 5986. Strongly recommended.
    • TLS skip verify — accept self-signed WinRM listener cert. OK for initial bring-up, disable for prod.
    • Auth Typentlm, basic, or kerberos; leave empty to use the credential's auth fields (legacy). Setting it here overrides the credential's auth_type/domain/kdc_host as a group. For Kerberos, set the Realm (and optionally a KDC Address) and address the target by its AD FQDN, never an IP.
    • Credential: pick the one from the step above.
    • Certificate store: usually WebHosting or My (Personal).
    • Binding: site name + binding IP (* = All Unassigned) + port (default 443). In Single Binding mode a Host Header sub-mode selects the exact binding (auto-select / no-SNI / specific hostname).
    • Apply mode: how many bindings one run updates — see Apply modes below.
    • Remove old cert after bind: optionally delete the previously bound certificate from the store after a successful bind — only if no other IIS SSL binding still references it.
    • Connect timeout / Command timeout: default 15 s / 60 s (allowed 5–120 s and 10–300 s).
    • App pool name (app_pool_name) — comma-separated pool names. When set it overrides auto-detection: exactly these pools are recycled, regardless of which bindings changed.
    • Restart app pool (restart_app_pool) — recycle the pool(s) after a successful binding update. Off by default.
    • Restart scope (restart_scope) — site_only (default) or site_and_subapps.
    • Certificate store (cert_store) — My (default) or WebHosting; nothing else is accepted.
    • Per-host deploy timeout (target_timeout_seconds, 30-3600): the whole time budget for deploying to one host (upload → import → bind → validate → app-pool recycle). Blank/0 = auto (~4× the command timeout + slack). Raise it for slow hosts or a large PFX over slow WinRM.
  3. Save → health check runs Invoke-Command -ScriptBlock { hostname } over WinRM.

Apply modes

apply_mode controls how many HTTPS bindings a single run updates. single, all_matching, and explicit_list are scoped to one IIS site (site_name); all_sites covers the whole machine.

ModeBehaviour
single (default)Updates one binding, selected via the Host Header sub-mode. Fails with IIS_BINDING_AMBIGUOUS if the filter matches more than one binding.
all_matchingUpdates every HTTPS binding on the site matching Binding IP + Port (or binding_ports; see below). Host header is ignored, but excluded_bindings can skip named host headers. Ideal for SNI sites where hundreds of hostnames share one wildcard/SAN certificate — the PFX is imported once and all bindings are updated in a single WinRM session, with a per-binding result report. One binding failing does not abort the rest (the run is marked partial).
explicit_listUpdates only the listed host headers (host_headers) on the site, each matched against the same Binding IP + Port. Headers with no matching binding are reported and downgrade the run to partial (or fail it if none match).
all_sitesUpdates the matching HTTPS bindings on every site on the machine (Binding IP + Port, or binding_ports; site_name is not set). Optional excluded_sites skips named sites and excluded_bindings skips named host headers (both case-insensitive; see below). Stopped sites are included. With Restart App Pool on, only the pools of sites whose bindings were actually updated are recycled.
all_sites is machine-wide — Dry Run first

all_sites will touch every non-excluded site whose bindings match the IP + port filter. Dry Run reports total/per-site match counts (and how many bindings are CCS-backed) before anything changes — always run it before the first execute.

Multiple ports and per-binding exclusions

all_matching and all_sites accept two extra filters:

FieldEffect
binding_portsMatch bindings on any of these ports (e.g. [443, 8443]), not just a single Binding Port. Empty = the single binding_port (default 443). One target can therefore cover a mix of ports — e.g. public 443 and internal 8443 — in one run.
excluded_bindingsHost headers to skip across all matched sites (case-insensitive), e.g. ["legacy.example.com"]. Matching-but-excluded bindings are reported (excluded in the plan / bindings_excluded counter) and left untouched; the rest are still updated.

Both are only valid with all_matching and all_sites (the module enumerates bindings itself in those modes). Example: all_sites, binding_ports=[443,8443], excluded_sites=["Admin Site"], excluded_bindings=["legacy.example.com"] updates every 443/8443 HTTPS binding on the machine except those on Admin Site or whose host header is legacy.example.com — a binding on 9443 is not touched.

Central Certificate Store bindings are skipped

Bindings with the CCS flag (sslFlags bit 2) load their certificate from a file share, not the machine certificate store — a store-thumbprint bind cannot update them. All multi-binding modes report such bindings as skipped_ccs and mark the run partial so they are visible rather than silently wrong.

For very large machines, the per-binding result list stored on the distribution is capped at 200 entries in all_sites mode (binding_results_truncated: true is set); the scalar counters (bindings_updated, bindings_failed, sites_updated, …) remain authoritative. Consider raising the target's command timeout when thousands of bindings match.

Per-distribution binding overrides

One IIS target (the WinRM connection) can serve many certificates. When linking a certificate to a distribution, open the certificate's Distributions → Overrides drawer and set the IIS binding placement for that certificate. The override replaces the target's whole placement (except the site, which is inherited when you leave it empty) and the effective apply mode is derived from which fields you fill (a * default row applies to targets without a specific entry):

Override fieldsEffective modeWhat gets updated
host_header / binding_port / binding_ip (+ optional site_name)singleExactly one existing HTTPS binding
binding_ports list (+ optional excluded_bindings, site_name)all_matchingEvery existing HTTPS binding on the site at the listed ports — host headers are ignored by this mode (e.g. cert A → ports 443, 8443, 9443, cert B → ports 7443, 6443, one target)
host_headers list (+ optional single binding_port, site_name)explicit_listExactly the listed host headers at one port

site_name is optional: leave it empty and the override applies to the target's own site, which is the common "same site, a different port or host header per certificate" case. Fill it only to move this certificate to another site. The one exception: a target in all_sites mode has no single site to inherit, so a per-target override there must name a site (422 at link time). The Default (all targets) row is not bound to one target, so a site-less default that lands on an all_sites target is caught at deploy instead — that target fails with IIS_SITE_NOT_FOUND (Dry Run reports the same).

binding_ports and host_headers are mutually exclusive (422): IIS's own matching modes don't combine them — the ports sweep ignores host headers and the explicit list is single-port. all_sites stays target-level (a machine-wide sweep is not a per-certificate placement). Keep different certificates' port/host-header sets disjoint — overlapping sweeps would re-bind each other's bindings on every renewal.

  • The HTTPS binding must already exist on the site — deploys update bindings, they never create them (IIS_BINDING_NOT_FOUND otherwise; Dry Run catches it first).
  • host_header is tri-state: omitted = any binding on the site, an explicit empty value (the "Pin the catch-all binding" checkbox, or "" via the API) = only the no-host-header binding, a hostname = exact match.
  • An override activates as soon as any field is filled; an entirely empty entry is rejected (omit the override to keep the target's placement).
  • Overrides carry placement only. Behavior stays on the target: app pool recycling, certificate store, remove_old_cert_after_bind, timeouts — and the machine-wide all_sites sweep, which is never a per-certificate placement.
  • Don't point two certificates at the same binding (or overlapping port/host-header sets): an IIS binding holds exactly one certificate, so each renewal would flip it back and forth.
  • Certificates coexist in the Windows store under distinct thumbprints, each with its own per-certificate remote state; concurrent deploys to the same host are serialized by a per-device lock (shared with the WinRM and Exchange modules, so cross-module deploys to one box serialize too).
  • Trust model: setting overrides is a project operator action, like creating the target itself — but a target change goes through the configuration-approval gate (on by default), an override does not — so it deliberately delegates per-certificate placement on reviewed targets, and every change is audited with the actor and full payload.

Choosing from the server (live pickers)

Both the IIS target form and the per-distribution override drawer can read the server and offer what is actually there instead of asking you to type names from memory:

  • Sites — every site, with its state, application pool and how many HTTPS bindings it has (a site with none cannot serve a certificate).
  • Bindings — the site's HTTPS bindings, each labelled with the certificate it carries right now (subject and expiry) so you can see at a glance whether picking it would displace an unrelated certificate. The binding IP, port and host-header pickers are all views over this one listing.
  • Application pools — for the recycle field.

The lists are read-only (Get- cmdlets only), fetched over the target's normal WinRM connection, and rate-limited to 60 requests per minute per project. Large machines are capped at 500 entries per listing. Nothing is read until you open a list — opening a form never touches your servers, so a drawer covering ten targets does not dial ten machines.

Which bindings would this touch?

Next to the placement fields — in the target form and in each certificate's per-target override row (not the Default row, which spans several machines) — Preview affected bindings asks the server to resolve the placement you have typed and lists exactly what a deploy would update, with the certificate currently on each binding. It needs no certificate and no saved distribution, and it changes nothing.

It exists because the two ways a placement goes wrong are invisible while typing:

  • Too wide. A site name with no host header or port matches every HTTPS binding on the site. In single-binding mode a deploy then fails with IIS_BINDING_AMBIGUOUS — the preview lists the bindings it caught and says so, instead of stopping at the first one.
  • Too narrow, or a typo. Nothing matches (IIS_BINDING_NOT_FOUND), or a host header in an explicit list exists nowhere on the site — which downgrades a real run to partial. Both are flagged before you save.

Rows are labelled will be updated, excluded (host header in excluded_bindings), central store — skipped (a Central Certificate Store binding the deploy skips) or no such binding. The matching is the same the deploy uses, so the list is the real blast radius rather than an estimate. The preview runs only when you ask for it; once open it re-runs shortly after you stop editing, never on every keystroke.

Two rules to know:

  • Typing always works. Every picker has a manual-entry toggle, and it falls back to a plain input when the server can't be reached, so an unreachable host never blocks the form. When a listing fails, the server's own reason is shown (for example that the site does not exist) rather than a generic error.
  • Who may browse. Listing through a saved target is a project operator action (same level as setting overrides). Browsing from a target form that hasn't been saved yet sends the connection details you just typed and is therefore restricted to project admins — a saved target has been through the configuration-approval gate, an unsaved one has not. The same rule covers the credential: a saved target is browsed with the credential it was saved with; pairing it with a different credential (changing the dropdown on an existing target before saving) also needs project admin, because that pairing is exactly what the approval reviewed. In the override drawer the "Default (all targets)" row has no single server behind it, so its fields stay manual-entry.

Dry Run

Dry Run connects but changes nothing. It lists a per-binding plan — one entry per matched binding showing the site, the binding, and the action:

  • update — the binding would be (re)bound to the new certificate.
  • already-current — the binding already serves this certificate.
  • skipped_ccs — a Central Certificate Store binding that the deploy will skip (multi-binding modes only; a single deploy binds CCS bindings like any other, so its plan never shows this).
  • excluded — a binding whose host header is in excluded_bindings; skipped.
  • missing — an explicit_list host header with no binding on the site; the deploy reports it and degrades to partial.

The plan selects bindings exactly as the deploy does, mode by mode: single applies the host-header filter and the "more than one binding" check, explicit_list lists only the named host headers (first binding per header), all_matching lists every binding on the port set minus excluded_bindings. all_sites also shows a summary line (sites, excluded, matched_sites, matched_bindings, ccs_skipped, excluded_bindings, and per-site counts). Always Dry Run before the first execute to confirm the scope.

SNI bindings are read like any other

The current thumbprint is read from each binding's own certificateHash, which IIS exposes per binding — SNI bindings included. So a binding that already carries the new certificate is reported already-current in Dry Run whatever its SNI flag, a multi-binding sweep of an unchanged certificate updates nothing and reports Changed = false, and Remove old cert after bind finds the previous certificate of an SNI binding. (Verified on Windows Server 2025 / IIS 10, where certificateHash is the hex thumbprint string; a host returning raw bytes is converted the same way.)

Execution flow

  1. Backend builds a PKCS#12 bundle from cert + key + chain, password-wrapped with an ephemeral passphrase.
  2. Uploads the PKCS#12 to a temp file under C:\Windows\Temp and locks it down with icacls to the executing user, SYSTEM and Administrators. The bundle travels on the remote command's standard input rather than its command line, so the key material never reaches Windows process-creation auditing, and the target verifies the content it received before the file is put in place. Each upload is bounded by the target's command timeout, like every other remote command.
  3. Uploads the bundle's password as its own file (same transfer, same lock-down) — it never appears in a script or an error message.
  4. Imports with Import-PfxCertificate into the configured store (skipped when the store already holds the thumbprint), then replaces the HTTPS binding's SSL cert on the IIS site ((Get-WebBinding).AddSslCertificate) — one binding in single mode, each selected binding independently in the multi-binding modes, where a binding that already carries the thumbprint is left untouched.
  5. single mode only: a second script validates that the thumbprint is in the store and that the binding (and the site/port's IIS:\SslBindings entries) carry it. A failed validation fails the target with IIS_VALIDATE_STORE / IIS_VALIDATE_BINDING; the result still reports whether the bind changed the binding, so auto-rollback can act on it.
  6. Optionally recycles the site's app pool(s).
  7. Optionally removes the previously bound certificate from the store (Remove old cert after bind) — only when the binding actually changed and no other IIS SSL binding still references it; not during a rollback.
  8. Deletes the temp files (the script's own finally, and again from the backend regardless of how the run ended), then emits the result (per-binding in multi-binding modes).
Old certificates in the store

By default old certificates stay in the store untouched — deleting them blindly can break other applications on the same host. Enable Remove old cert after bind to have the module delete the previously bound certificate after a successful bind, but only when no other IIS SSL binding on the machine still references it. Anything older than that (or referenced elsewhere) still needs an occasional manual sweep (e.g. Get-ChildItem Cert:\LocalMachine\WebHosting | Where NotAfter -lt (Get-Date)).

Retries & partial results

Failures are classified so fan-out can auto-retry the transient ones: a dropped/timed-out WinRM connection (IIS_CONNECT) or a failed PFX transfer is retried; a genuine binding/validation error (IIS_BINDING_NOT_FOUND, IIS_VALIDATE_BINDING, …) is not — re-running can't fix it. The bind is idempotent (a cert already in the store is not re-imported; in the multi-binding modes a binding already carrying the thumbprint is left alone, and in single mode the bind call is repeated but binding the same certificate again changes nothing — the result then reports Changed = false), so a retry after a partial network failure is safe. Full class table: Distribution error codes.

A multi-binding run reports partial when some bindings succeed and others are skipped or fail — binding_update_partial when bindings failed or CCS bindings were skipped, binding_not_found when some requested host headers had no binding.

No command-line size limits

Multi-binding and all_sites generate large PowerShell scripts. To stay clear of the Windows command-line length limit, any script above a small threshold is streamed to a temporary .ps1 on the target (over the file transport) and run with -File, then deleted — so machine-wide runs work regardless of how many bindings match. This is automatic and needs no configuration.

Rollback

Supported via previous-version re-deploy: a rollback re-imports a previous retained certificate version's PFX into the certificate store and repoints the HTTPS bindings at its thumbprint — the module's normal deploy path, fed older material. During a rollback run the remove_old_cert_after_bind behavior is suppressed, so the newer certificate stays in the Windows certificate store and rolling forward again is instant. Eligibility, the version picker, and auto-rollback: Rollback.

Troubleshooting

IIS_CONNECT ... PFX transfer failed ... 401 - invalid content type

The most common failure, and it is almost always authorization — not the transfer itself. WinRM returns HTTP 401 (an unauthenticated/denied response with a non-SOAP body, which the client reports as "invalid content type"). Work through, in order:

  1. Is the account a local Administrator? WinRM only admits Administrators / Remote Management Users, and the cert import needs admin. Check + fix:

    net user <user> # Account active? Password expires?
    net localgroup Administrators | findstr /i <user>
    net localgroup Administrators <user> /add
  2. Is LocalAccountTokenFilterPolicy = 1 set? A local admin over NTLM gets a filtered (non-admin) token without it, so WinRM denies the operation:

    (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').LocalAccountTokenFilterPolicy # must be 1

    (See Prerequisites for the command to set it.)

  3. Plain HTTP (5985) without AllowUnencrypted? The client does not encrypt the WSMan body, so over HTTP the service must have AllowUnencrypted="true" — for NTLM, Basic and Kerberos alike (typically an empty 500 after auth succeeds, or a 401 for Basic). Basic additionally needs Basic="true" and a bare username (user, not .\user):

    winrm get winrm/config/service/auth # Basic = true ?
    winrm get winrm/config/service # AllowUnencrypted = true ?
    winrm set winrm/config/service/auth '@{Basic="true"}'
    winrm set winrm/config/service '@{AllowUnencrypted="true"}'
    Restart-Service WinRM
  4. NTLM PFX transfer fails on large files. If auth and authorization are correct (small commands / the health check pass) but only the PFX transfer 401s, this is a known limitation of Go's NTLM client with large WinRM request bodies. Switch the target to Basic (over HTTPS 5986 for production, or HTTP 5985 with AllowUnencrypted for a POC) — Basic has no per-connection handshake, so large transfers succeed.

  5. NTLM disabled on a hardened / domain-joined box. If every auth attempt 401s (not just the large transfer), the WinRM listener may accept only Kerberos. Check what it offers — on the box winrm get winrm/config/service/auth (Negotiate must be true), or from anywhere a raw POST returns the accepted methods:

    curl -s -i -X POST http://<host>:5985/wsman \
    -H "Content-Type: application/soap+xml;charset=UTF-8" --data x | grep -i WWW-Authenticate

    If it lists only Negotiate + Kerberos and NTLM keeps failing, incoming NTLM is blocked by Network security: Restrict NTLM: Incoming NTLM traffic (commonly enforced by a domain GPO). Three options:

    # (a) Allow incoming NTLM. On a domain box this is usually GPO-controlled — set it in the
    # domain GPO or it reverts on the next gpupdate/reboot. Local override for a quick test:
    New-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' `
    -Name RestrictReceivingNTLMTraffic -Value 0 -PropertyType DWORD -Force # 0=Allow all
    Restart-Service WinRM
    (b) Switch the IIS TARGET to Kerberos. Set Auth Type = kerberos on the target together
    with the Realm (uppercase AD domain, e.g. CORP.LOCAL); the credential keeps its
    bare username, and address the target by its AD FQDN — Kerberos does not
    authenticate against a bare IP. Nothing needs to be installed on the CertAutoPilot
    host: the Kerberos configuration is built in memory and the domain controller is
    discovered automatically via the AD DNS SRV record (or pinned with the optional
    KDC Address field when this host's DNS cannot resolve AD records). This matches
    exactly what a hardened GPO baseline leaves enabled (Negotiate/Kerberos = true).
    (c) Or set the TARGET's Auth Type to Basic over HTTPS (port 5986). This only works if Basic
    CAN be enabled — check `winrm get winrm/config/service/auth`:
    `Basic = false [Source="GPO"]` means Basic is also policy-locked and cannot be
    turned on from the box.

    An unrecognized auth_type on an IIS credential is now rejected at save time — it no longer falls back to NTLM silently.

Confirm what the server sees: on the target, the Security event log records the WinRM logon. A successful logon is event 4624 (Logon Type 3, NTLM V2) — if you see 4624 (not 4625) but the deploy still fails, authentication works and the problem is authorization (steps 1–2) or the large-body case (step 4), not the password.

"Failed to connect to WinRM"

Check winrm quickconfig on the target, confirm the listener exists (winrm enumerate winrm/config/listener), and verify the firewall allows the port. From the backend host: Test-NetConnection <host> -Port 5985 (or 5986). If the target field points at the IIS site port (443/80) instead of the WinRM port (5985/5986), you also get a 401 with an HTML body — put the WinRM port in Port, and the site port in Binding Port.

Port 5986 open, but the TLS handshake resets

If a connection to 5986 is accepted and then reset during the TLS handshake (connection reset / abrupt close before any HTTP response), the WinRM HTTPS listener has no usable server certificate bound — the port answers because the listener exists, but Windows aborts the handshake when it cannot present a certificate (missing, expired, or its private key inaccessible). Fix it on the target:

winrm enumerate winrm/config/listener # HTTPS listener present? CertificateThumbprint set and valid?
# Bind a server-auth certificate (from your internal CA; CN/SAN must match the hostname CAP dials):
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint <thumbprint> -Force
Restart-Service WinRM

The backend must trust the certificate's chain, or the target must set TLS Skip Verify (not recommended for production). Until the listener is fixed, the target can run over HTTP 5985 with NTLM.

IIS_MODULE_MISSING

The WebAdministration PowerShell module isn't on the target. Install it: Install-WindowsFeature Web-Scripting-Tools (IIS Management Scripts and Tools).

"Access is denied" during Import-PfxCertificate (IIS_AUTH)

The account lacks permission to write to the LocalMachine cert store — it is not a local Administrator, or LocalAccountTokenFilterPolicy is unset so its admin token is filtered. See step 1–2 above. The target fails with IIS_AUTH. A different message, "Access to the path 'C:\Windows\Temp\…' is denied", is a filesystem permission problem on the staged temp file and is reported as IIS_IMPORT_FAILED — check the temp directory's ACLs, not the account.

A bind exception (IIS_BINDING_FAILED)

In single mode an exception from AddSslCertificate (for example when HTTP.sys refuses the certificate) fails the target with IIS_BINDING_FAILED and the exception's own message; the certificate is already in the store. In the multi-binding modes the failing binding is recorded individually and the run is partial (or IIS_BINDING_FAILED when no binding could be updated).

IIS_BINDING_NOT_FOUND — no binding matched

No HTTPS binding on the site matched the requested IP/port (or host header). The run fails with IIS_BINDING_NOT_FOUND — it is never a silent no-op. The usual cause is a site name that does not match exactly; the module never creates bindings, so the binding must already exist. IIS site names are matched case-insensitively but must otherwise be exact — including any trailing spaces. Confirm with Get-IISSite on the target.

IIS_SITE_NOT_FOUND — site does not exist, or no site at all

Either the named site does not exist on the server (check Get-IISSite; pick it from the site picker instead of typing it), or the resolved placement has no site: a per-certificate override that leaves the site empty inherits the target's, and an all_sites target has none to inherit. Per-target overrides of that shape are refused when linking; the Default (all targets) row is not bound to one target, so it fails at deploy (and Dry Run) with this code instead.

See also

Warnings

A run can succeed and still carry warnings, shown in the Warnings column of the target result:

CodeMeaning
IIS_TEMP_CLEANUP_FAILEDThe uploaded PFX/password temp files could not be removed from the host.
IIS_INSECURE_TRANSPORTtls_skip_verify is on: the WinRM HTTPS listener certificate was not verified. (Plain HTTP 5985 does not raise it — that case only writes a job-log warning when the auth type is Basic.)
IIS_LARGE_PFXThe PFX was large enough to risk WSMan quota limits.
IIS_PARTIAL_POOL_RECYCLESome application pools recycled, others failed.
IIS_ALL_POOL_RECYCLE_FAILEDEvery pool recycle failed (the certificate is still bound).
IIS_IPV6_BINDING_SKIPPEDAn IPv6 binding was skipped.
IIS_OLD_CERT_IN_USEThe previous certificate was left in place because another binding still uses it.
IIS_OLD_CERT_CLEANUP_FAILEDRemoving the previous certificate from the store failed.
IIS_CCS_BINDING_SKIPPEDA Central Certificate Store binding cannot be updated through the machine store and was skipped.