> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Hosting a Website on IIS — Windows Server Without the Bloat
- URL: https://blog.sudowl.com/iis-host-website-windows-server/
- Published: 2026-05-28T10:00:00.000Z
- Updated: 2026-10-11T08:26:51.000Z
- Description: Set up IIS from PowerShell without the bloat — install only the role services you need, create an app pool and site, bind HTTPS, harden, verify. No clicking through the GUI.
- Author: Lukas
- Tags: Windows Server

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:**

```powershell
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.

```powershell
# 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:

```powershell
# 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:

```powershell
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.

```powershell
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.

```powershell
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 "").

```powershell
# "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).

```powershell
# 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:

```powershell
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

```powershell
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.

```powershell
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`.

```powershell
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:

```powershell
# 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:

```powershell
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.

```powershell
# 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:

```powershell
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:

```powershell
# 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](https://blog.sudowl.com/nginx-reverse-proxy-setup/) is a 15-line config file, and [Caddy gives you automatic HTTPS](https://blog.sudowl.com/caddy-vs-nginx-auto-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

```powershell
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:

```powershell
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](https://blog.sudowl.com/windows-server-initial-setup-checklist/) for the baseline this site sits on.

---

### Related posts

- [Hyper-V on Windows Server — Your First VM Without the GUI](https://blog.diengdoh.com/hyper-v-create-first-vm-windows-server/?ref=blog.sudowl.com)
- [DHCP on Windows Server — Scopes, Reservations, and the Gotchas](https://blog.diengdoh.com/windows-server-dhcp-setup/?ref=blog.sudowl.com)
- [Active Directory Part 2 — OUs, Users, and GPO Basics](https://blog.diengdoh.com/active-directory-part-2-ous-users-gpo-basics/?ref=blog.sudowl.com)