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) or Basic. Basic only works over HTTPS WinRM or when AllowUnencrypted is set on the target — usually unsuitable for production.
  • 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; old thumbprint left in the store (not deleted).
  • Rollback: not supported. The old thumbprint remains in the store for manual re-bind if needed.

Prerequisites

  • WinRM enabled on the target: Enable-PSRemoting -Force.

  • Firewall rule opening 5985 (HTTP WinRM) or 5986 (HTTPS WinRM) between the backend and the IIS host. For HTTPS WinRM make sure the WinRM listener has a valid cert bound:

    winrm quickconfig -transport:https
  • An administrative Windows account with permission to manage certificates + IIS bindings. Adding it to the Administrators group is easiest; a constrained JEA endpoint also works if the commands used by the module are on the allowlist.

  • IIS installed with the Web-Mgmt-Service feature.

Create the module credential

  1. Settings → Distribution → CredentialsNew.
  2. Module type: IIS (WinRM).
  3. Username (in DOMAIN\\user or user@FQDN form), password.
  4. Save.

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 type: ntlm (default) or basic.
    • 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: reasonable defaults (30 s / 120 s).
  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.

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 (non-SNI only, see below).
  • skipped_ccs — a Central Certificate Store binding that will be skipped.
  • excluded — a binding whose host header is in excluded_bindings; skipped.

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 always re-bind

Windows stores the certificate for an SNI binding keyed by hostname in HTTP.sys, not on the Get-WebBinding object — so the module cannot read a per-SNI-binding "current" thumbprint. Consequently SNI bindings are always reported as update in Dry Run and re-bound on every run (harmless — binding the same cert is idempotent), already-current detection applies to non-SNI bindings only, and Remove old cert after bind cannot identify the previous cert for SNI bindings.

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 on the target and imports with Import-PfxCertificate into the configured store.
  3. Replaces the HTTPS binding's SSL cert on the IIS site ((Get-WebBinding).AddSslCertificate).
  4. Deletes the temp file.
  5. Optionally recycles the site's app pool(s), 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; a binding already correct is left alone), 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 (via the chunked 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

Not supported. The distribution record's RollbackAvailable is false. To revert, open IIS Manager on the target and re-bind the site to the previous thumbprint (which is still in the store).

Troubleshooting

"Failed to connect to WinRM"

Check winrm quickconfig on the target, confirm the HTTPS listener exists (winrm enumerate winrm/config/listener), and verify the firewall allows 5986. From the backend host: Test-NetConnection <host> -Port 5986.

"Access denied" during Import-PfxCertificate

The service account lacks permission to write to the cert store. Add it to local Administrators or grant store-specific ACL.

Binding update silently does nothing

The site name in the target config doesn't exactly match. IIS site names are matched case-insensitively but must otherwise be exact — including any trailing spaces. Confirm with Get-IISSite on the target.

See also