IIS ships with Windows Server and most people install the whole role with every sub-feature, then click through inetmgr. Both are mistakes. Install the role services you actually use, and drive the whole thing from PowerShell.

TL;DR:

Install-WindowsFeature Web-Server -IncludeManagementTools
Import-Module IISAdministration
New-WebAppPool -Name "site1"
Set-ItemProperty IIS:\AppPools\site1 -Name managedRuntimeVersion -Value ""
New-Website -Name "site1" -PhysicalPath "C:\inetpub\site1" -Port 80 `
  -HostHeader "example.com" -ApplicationPool "site1"
Remove-Website -Name "Default Web Site"
icacls "C:\inetpub\site1" /grant "IIS AppPool\site1:(OI)(CI)RX"

Details below.

Install only what you need

The default Install-WindowsFeature Web-Server already pulls a sane base set. The trap is people adding every sub-feature "just in case", or scripts copied off forums that install ASP, CGI, FTP, and WebDAV on a box that serves static files.

# Base role + management tools (inetmgr + the PowerShell modules)
Install-WindowsFeature Web-Server -IncludeManagementTools

What you actually need on top of the base depends on the workload:

  • Static site / SPA: nothing extra. The base includes Web-Static-Content, Web-Default-Doc, Web-Http-Errors, Web-Http-Logging.
  • Reverse proxy (backend on another box/port): no .NET, no ASP.NET. You add ARR + URL Rewrite separately (see below) — those are out-of-band installers, not role services.
  • ASP.NET Core app: the Hosting Bundle from Microsoft, not Web-Asp-Net45. The Hosting Bundle installs the ANCM module and the runtime. Don't install the legacy .NET 4.x ASP.NET feature unless you genuinely run a .NET Framework app.

What you almost never need, and should not install reflexively:

# DON'T install these unless a specific app requires them:
#   Web-CGI, Web-DAV-Publishing, Web-Ftp-Server,
#   Web-Asp (classic ASP), Web-Includes (SSI)

Each one is attack surface. Check what got pulled in:

Get-WindowsFeature Web-* | Where-Object Installed | Select-Object Name

Load the right module

There are two. WebAdministration is the old one (IIS:\ drive, Get-Website, etc.). IISAdministration is the newer, faster, server-manager-based module.

Import-Module IISAdministration   # newer, preferred
# or
Import-Module WebAdministration    # older, still ships, IIS:\ drive

I use IISAdministration for new work but reach for WebAdministration cmdlets like New-Website and the IIS:\ PSDrive when they're more ergonomic — they coexist fine in one session. The examples here mix both deliberately.

App pool — and the .NET runtime gotcha

One app pool per site. Default isolation, default identity, until you have a reason otherwise.

New-WebAppPool -Name "site1"

The single most common mistake: leaving the pool on .NET CLR v4.0 for a site that runs no managed .NET in-process. Static content and reverse proxies need No Managed Code. ASP.NET Core also runs No Managed Code because the app runs out-of-process (or in-process via ANCM, which still wants the CLR set to "").

# "No Managed Code" = empty managedRuntimeVersion
Set-ItemProperty IIS:\AppPools\site1 -Name managedRuntimeVersion -Value ""

# Only set v4.0 if you actually run .NET Framework (ASPX, classic MVC):
# Set-ItemProperty IIS:\AppPools\site1 -Name managedRuntimeVersion -Value "v4.0"

Leaving an unnecessary CLR loaded wastes memory per pool and loads managed modules you don't use.

Pool identity and the ACL gotcha

ApplicationPoolIdentity is the default and the right answer for almost everything. It's a virtual account IIS AppPool\site1 — no password, automatically distinct per pool. Use a real service account only when the app needs network resources under a specific identity (a SQL login via Windows auth, a file share).

# Default — leave it:
Get-ItemProperty IIS:\AppPools\site1 -Name processModel.identityType
# ApplicationPoolIdentity

# Service account, only if you need one:
# Set-ItemProperty IIS:\AppPools\site1 -Name processModel `
#   -Value @{identityType=3; userName="DOMAIN\svc-web"; password="..."}

The gotcha: the pool identity needs read/execute on the site folder. ApplicationPoolIdentity is not a member of any default group with rights to C:\inetpub\site1 if you created that folder yourself. Grant it explicitly or you get HTTP 500.19 / access-denied:

icacls "C:\inetpub\site1" /grant "IIS AppPool\site1:(OI)(CI)RX"

(OI)(CI)RX = read+execute, inherited to files and subfolders. That's all a read-only web root needs. Don't grant write unless the app uploads or writes logs into the root (it shouldn't).

Create the site, kill the default

New-Website -Name "site1" `
  -PhysicalPath "C:\inetpub\site1" `
  -Port 80 `
  -HostHeader "example.com" `
  -ApplicationPool "site1"

-HostHeader matters once you host more than one site on the same IP/port — IIS routes by Host header. Always set it; relying on the catch-all binding bites you the day you add a second site.

Remove the default site. It listens on *:80 with no host header, which means it answers for any hostname you haven't explicitly bound — a soft information leak and a routing surprise.

Remove-Website -Name "Default Web Site"
# and the default pool if nothing else uses it:
Remove-WebAppPool -Name "DefaultAppPool"

HTTPS binding

Add the binding, then attach a cert by thumbprint. The cert must already be in Cert:\LocalMachine\My.

New-WebBinding -Name "site1" -Protocol https -Port 443 -HostHeader "example.com" -SslFlags 1

-SslFlags 1 enables SNI — required when multiple HTTPS sites share an IP. Now bind the cert. Two ways:

# IIS:\SslBindings (per host:port with SNI)
$thumb = (Get-ChildItem Cert:\LocalMachine\My |
  Where-Object Subject -like "*example.com*").Thumbprint
New-Item -Path "IIS:\SslBindings\!443!example.com" -Thumbprint $thumb -SSLFlags 1

Or with netsh, which is what actually writes the HTTP.sys binding under the hood:

netsh http add sslcert hostnameport=example.com:443 `
  certhash=$thumb appid="{00000000-0000-0000-0000-000000000000}" certstorename=MY

For a fleet of servers sharing certs, look at Centralized Certificate Store (Web-CertProvider): certs live as .pfx files on a UNC share, named by hostname, and every node binds from there. One renewal, all nodes pick it up — no per-server cert juggling. Overkill for a single box, essential for a farm.

Harden it

Remove the Server: Microsoft-IIS/10.0 header, set request-filtering limits, send HSTS.

# Strip the Server header (IIS 10+)
Set-WebConfigurationProperty -PSPath "IIS:\Sites\site1" `
  -Filter "system.webServer/security/requestFiltering" `
  -Name "removeServerHeader" -Value $true

# Cap request size / URL length (request filtering)
Set-WebConfigurationProperty -PSPath "IIS:\Sites\site1" `
  -Filter "system.webServer/security/requestFiltering/requestLimits" `
  -Name "maxAllowedContentLength" -Value 30000000

HSTS is native from Server 2019 onward — no rewrite rule needed:

Set-WebConfigurationProperty -PSPath "IIS:\Sites\site1" `
  -Filter "system.webServer/httpProtocol/hsts" `
  -Name "." -Value @{enabled=$true; "max-age"=31536000; includeSubDomains=$true}

Older builds: add Strict-Transport-Security via a URL Rewrite outbound rule, but only over HTTPS — never send HSTS on the plaintext binding.

Reverse proxy: ARR + URL Rewrite

If IIS is fronting an app on another port or host, you want Application Request Routing plus URL Rewrite. Neither is a Windows feature — both are Web Platform installs (or standalone MSIs). Once installed, enable the proxy and rewrite to the backend:

# Enable proxy at the server level
Set-WebConfigurationProperty -PSPath "MACHINE/WEBROOT/APPHOST" `
  -Filter "system.webServer/proxy" -Name "enabled" -Value $true

Then a rewrite rule in the site's web.config matching (.*) and rewriting to http://10.0.0.20:8080/{R:1}. It works, and on a Windows-only shop it's the path of least resistance.

But be honest about it: ARR + URL Rewrite is heavier and clunkier than the Linux equivalents. If reverse proxy is the whole job, an nginx reverse proxy is a 15-line config file, and Caddy gives you automatic HTTPS with zero cert wrangling — no Centralized Certificate Store, no netsh http add sslcert, no thumbprints. I run IIS when the backend is a Windows/.NET app that has to live on the same box anyway. For a standalone proxy tier, I don't reach for IIS.

Verify

Get-Website | Select-Object Name, State, PhysicalPath, @{n='Bindings';e={$_.bindings.Collection.bindingInformation}}

# Pool state + runtime
Get-IISAppPool | Select-Object Name, State, ManagedRuntimeVersion

# Hit it locally
Invoke-WebRequest http://localhost -Headers @{Host="example.com"} -UseBasicParsing |
  Select-Object StatusCode, StatusDescription

StatusCode 200, pool Started, no Server header in the response ((Invoke-WebRequest ...).Headers should not list it). HTTPS:

Invoke-WebRequest https://example.com -UseBasicParsing | Select-Object StatusCode

Gotchas

  • App pool identity has no folder rights by default. The number one IIS 500.19. If you created the web root by hand, grant IIS AppPool\<name> read+execute. The auto-created C:\inetpub\wwwroot already has it; your custom folders don't.
  • Wrong CLR on the pool. A reverse-proxy or static site on .NET v4.0 loads managed modules for nothing. Set managedRuntimeVersion = "". Conversely, a .NET Framework app on "No Managed Code" returns blank pages or 500s — match the runtime to the app.
  • Default Web Site left running. It answers for unbound hostnames and squats on *:80. Remove it or you'll fight binding conflicts the first time you add a second site.
  • HSTS on the HTTP binding. Sending Strict-Transport-Security over plaintext is ignored by spec and a misconfiguration smell. Only emit it on HTTPS responses.
  • netsh vs IIS:\SslBindings drift. Binding a cert two different ways (GUI + netsh, or both modules) can leave stale HTTP.sys entries. netsh http show sslcert is the source of truth — check there if a cert "won't bind".
  • Forgetting SNI flags. Multiple HTTPS sites on one IP need -SslFlags 1 on both the binding and the SSL cert binding, or one site serves the other's cert.

This fits into the broader build — see the Windows Server initial setup checklist for the baseline this site sits on.


Related posts