Skip to main content

Every time I build a server I go through the same sequence, and every time I find myself trying to remember what I did last time or whether I ever got round to locking down RDP.  This is my reference for setting up a clean Windows Server 2025 instance to host Umbraco sites on IIS with SQL Server Express.

The order matters, because some steps depend on earlier ones.  Regional settings first, then the firewall, then the web platform, then the database, then tooling, then per-site configuration.

Before you start, run Windows Update until it stops finding things and reboot.  Also make sure you have a way back in that is not RDP, such as your provider's console.  You will need it when you start restricting the firewall.

Regional settings

Timezone

Get this right early.  It affects IIS logs, Umbraco logs, scheduled tasks and backup filenames, and chasing an incident across logs that are eight hours out is miserable.

Open Settings, go to Time and Language, then Date and Time, and set the time zone to (UTC+00:00) Dublin, Edinburgh, Lisbon, London.  Leave "Adjust for daylight saving time automatically" on so British Summer Time is handled for you.  If you're in a different timezone, then adjust accordingly.

If you are on Umbraco 17 or later, note that all system dates are now stored and exposed in UTC with a built-in migration that converts existing data.  That does not remove the need to set the server timezone, but the CMS is less dependent on it than it used to be.

Language and keyboard

If the keyboard is on US layout you will spend the next hour typing hashes and getting pound signs, which is not fun when you are typing passwords!

In Settings, under Time and Language, go to Language and Region.  Set the country or region to United Kingdom, add English (United Kingdom) to the preferred languages list and move it to the top, then remove English (United States) along with its keyboard layout.  Under Regional Format, choose English (United Kingdom).

Then open the Administrative Language Settings link on the same page.  On the Administrative tab, use "Copy settings" to apply your choices to the welcome screen and new user accounts, and use "Change system locale" to set the locale for non-Unicode programs to English (United Kingdom).  Reboot afterwards, because the system locale change needs it.

Use a different language and keyboard layout as appropriate if you're not using English (United Kingdom).

Lock down the firewall

Do this before installing anything web-facing.  A fresh Windows Server has more inbound surface exposed than most people expect, and a box on a public IP gets found within minutes.

The two that matter are TCP 135 for the RPC endpoint mapper and TCP 445 for SMB.  Neither has any business being reachable from the internet on a web server.

Open Windows Defender Firewall with Advanced Security from Server Manager under Tools.  Select Inbound Rules, then New Rule in the Actions pane.  Choose Port, TCP, and enter 445 as the specific local port.  On the Action page choose "Block the connection", apply it to all three profiles, and name it something obvious.  Repeat for port 135.  Windows Firewall evaluates block rules before allow rules, so these win regardless of what else is enabled.

Still in Inbound Rules, sort by Group, find File and Printer Sharing, select all of its rules and disable them.

What you want left open inbound is TCP 80 and 443 for the websites and TCP 3389 for RDP.  Sort the Inbound Rules list by the Enabled column and work through what is left, disabling anything you cannot justify.

For RDP, open the properties of the "Remote Desktop - User Mode (TCP-In)" rule, go to the Scope tab, and under Remote IP address choose "These IP addresses" and add your own.  Repeat the same for "Remote Desktop - User Mode (UDP-In)".  This is the step where you can genuinely lock yourself out, so confirm your alternative access works first and confirm your IP is actually static rather than just having been the same for a while.  If you do not have a static IP, put RDP behind a VPN or an overlay network such as Tailscale and close 3389 entirely.

Install IIS

Roles and features

Open Server Manager, choose Add roles and features, and click through Installation Type (Role-based) and Server Selection to Server Roles.  Tick Web Server (IIS) and accept the management tools prompt.

Expand Web Server (IIS), then Web Server, and add the following on top of the defaults:

Under Common HTTP Features, tick HTTP Redirection.  Only needed if you are doing redirects at the IIS level rather than in application code or at the CDN, but it costs nothing and it is annoying to find it missing mid-deployment.

Under Health and Diagnostics, tick Tracing.  This is the most useful thing on the list when something is going wrong that you cannot reproduce.  Failed Request Tracing captures the full pipeline for requests matching a status code or a time threshold, which tells you which module returned the 500 or where the 40 seconds went.  Turn the rules off again when you are done, because the trace files will fill a disk given the chance.

Under Performance, tick Dynamic Content Compression.  Static compression is already on by default.  Dynamic compression compresses your HTML at the cost of CPU, so if the box is CPU-constrained or sitting behind Cloudflare you may reasonably leave it off.

Under Application Development, tick WebSocket Protocol. This is not optional if you want a working backoffice.  Umbraco uses SignalR for preview and server events, and SignalR works best over WebSockets, falling back to Server-Sent Events or long polling otherwise.  Umbraco's documentation notes that some hosting setups buffer streamed responses, which breaks the SSE fallback and shows up to editors as a "Could not establish a connection to the server" warning.

Also under Application Development, tick ASP.NET 4.8 if you are still hosting Umbraco 7 or 8 or classic ASP.NET on the same box.  Windows Server 2025 already ships with .NET Framework 4.8.1 preinstalled, and 4.8.1 runs anything targeting 4.x, so you are enabling the IIS integration rather than installing the framework.  Skip it if everything on the server is modern .NET.

URL Rewrite

URL Rewrite is not part of IIS. It is a separate download and it is what makes web.config rewrite and redirect rules work.  If you have ever deployed a site and found every rule in the <rewrite> section silently ignored, or a 500.19 on the <rewrite> element, this is why.

Download it from IIS.net URL Rewrite and install it before you deploy any sites.  It needs an IIS reset afterwards.  The configuration reference is at URL Rewrite Module Configuration Reference.

It is also what I reach for first when a site is under a crude attack and I need to block a pattern immediately.  That is a stopgap rather than a fix, but having it already installed means you can act in seconds.

.NET Hosting Bundle

This is what actually runs your sites.  It installs the .NET runtime, the ASP.NET Core shared framework, and the ASP.NET Core Module that lets IIS reverse-proxy to your application.

As of mid-2026 the version you need is .NET 10.  Both .NET 8 and .NET 9 reach end of support on 10 November 2026, after which Microsoft ships no further security patches.  .NET 10 is the current LTS and is supported through November 2028, and Umbraco 17 is built on it.  If you are still running Umbraco 13 you will need the .NET 8 bundle alongside, which is fine, but plan the migration.

Download from .NET Downloads and pick the Hosting Bundle under the Windows column, not the runtime or the SDK.  You do not need the SDK on a production web server.

Run the installer, then in IIS Manager select the server node and choose Restart from the Actions pane.  Confirm what you have with dotnet --list-runtimes.

By default the bundle installs both x64 and x86 runtimes.  There is an OPT_NO_X86=1 switch that skips the 32-bit ones, and you should not use it on a box where you intend to run 32-bit application pools.

Microsoft's hosting documentation is at Host ASP.NET Core on Windows with IIS.

Install SQL Server Express

Installation

Download Express from SQL Server Downloads and run the Basic or Custom installation.

Understand the limits before you commit.  Express capped a single database at 10GB from SQL Server 2008 R2 all the way through to 2022.  SQL Server 2025 raised that to 50GB, which makes it viable for a lot of sites it previously was not.  The compute limits did not move: the lesser of one socket or four cores, and roughly 1.4GB of buffer pool.

The size cap applies to the total size of the data file, not the used space inside it.  A 10GB file with 2GB free inside it has still hit the limit, and when you reach it you get error 1827 and writes start failing, which on an Umbraco site means the backoffice stops saving.

During setup, use the default instance name of SQLEXPRESS, which gives you a Windows service called MSSQL$SQLEXPRESS.  On the Server Configuration page, set the collation to a case-insensitive variant.  Umbraco's data access layer does not support case-sensitive naming, and the documented recommendation is SQL_Latin1_General_CP1_CI_AS.  Getting this wrong gives you an install that appears to work and then fails in confusing ways.

Choose Windows Authentication mode.  We will use the application pool identity for database access rather than putting a password in a connection string.  Leave TCP/IP disabled and the SQL Browser service off, since your sites connect over shared memory on the same box.

In Services, set the SQL Server (SQLEXPRESS) service to Automatic if it is not already.

One limitation to know about: Express does not include SQL Server Agent, so you cannot schedule jobs inside SQL Server.  See the backups section.

Umbraco's database requirements are at Umbraco CMS Requirements.

Set a memory limit

This is the one people skip and then spend a day diagnosing.  By default SQL Server's maximum server memory is set to an effectively unlimited value, and the engine will take everything it is allowed to.  On a shared box that means SQL Server and the IIS worker processes end up fighting for the same RAM.  Windows starts trimming working sets, pages get pushed out and pulled back, and what you see is high CPU and a server that feels slow across the board, with no single process obviously to blame.

Express caps its buffer pool at around 1.4GB regardless, but maximum server memory covers more than the buffer pool, including the plan cache and other caches.  Setting it explicitly keeps the engine inside a predictable envelope.

Open SSMS, right-click the instance in Object Explorer and choose Properties, then select the Memory page.  Set Maximum server memory (MB) to a sensible figure and click OK.  The change takes effect immediately without a restart.

For a web server running Express alongside IIS, 1024 to 2048 is usually right. There is no point going much above 2048 on Express given the buffer pool ceiling.  Work out what to leave for everything else rather than what to give SQL Server: reserve enough for Windows itself, for each IIS worker process at its recycle limit, and for anything else on the box, then give SQL Server what is left up to that ceiling.

While you are on that page, leave minimum server memory at 0 unless you have a specific reason.  Setting a minimum only creates the opposite problem.

SQL Server Management Studio

Download it from Install SQL Server Management Studio.  SSMS 22 is the current line as of July 2026 and installs through the Visual Studio Installer rather than the old standalone installer.  Run the bootstrapper as Administrator, pick the SQL Server Management Studio workload, and let it pull the components down.  There is an offline process documented at Create an offline installation if the server has no internet access.

Installing SSMS does not install or update the database engine.

Harden SSL and TLS with IIS Crypto

Windows ships with a broader set of protocols and cipher suites than you want on a public web server.  The settings live deep in the registry under SCHANNEL, and editing them by hand is tedious and easy to get wrong in a way that takes the server offline.

IIS Crypto from Nartac Software does the job with a button.  It covers Windows Server 2012 through 2025, and version 4.0 added 2025 support along with HTTP/3 and QUIC handling.  Download it from Nartac IIS Crypto.

Run it as Administrator, click Best Practices on the Schannel tab, click Apply, and reboot.  The reboot is required.

Two things to know.  These settings are machine-wide, so they affect RDP and anything else using TLS, not just IIS.  And if you have an old internal client or legacy API partner that only speaks TLS 1.0, Best Practices will break it, so test before you assume.  You can build a custom template starting from Best Practices if you need one exception, and save it to reuse on other servers.

Once sites are live, check the result with SSL Labs.

Install win-acme for free SSL certificates

There is no reason to pay for standard domain-validated certificates.  win-acme talks to Let's Encrypt and integrates directly with IIS.  It reads your site bindings, requests the certificate, installs it, binds it, and creates a scheduled task for renewal.

Download from win-acme.com or the GitHub releases page.  Take the current release, because Let's Encrypt has changed the ACME Renewal Information specification and older builds report errors against it.

There is no installer.  Extract it somewhere permanent such as C:\Tools\win-acme, because the scheduled task points at wherever you put it.  Extract it to a temp folder and delete it later and renewals stop working silently, which you find out about 90 days on.

Run wacs.exe as Administrator and take the simple option to create a certificate for an IIS site.  It lists your sites and handles HTTP validation by writing a file into the site's well-known path, so the site needs to be reachable on port 80 at the point you run it.  That is a good reason to keep a plain HTTP binding rather than forcing everything to HTTPS at the firewall.

Wildcards need DNS validation instead, and there are plugins for Cloudflare, Azure DNS and Route53.  More setup, but worth it if you are managing a lot of subdomains.

Afterwards, check the scheduled task exists in Task Scheduler.

Set up MailEnable as a local SMTP relay

Umbraco needs to send email for password resets, backoffice invitations and Forms notifications.  The simplest approach on a Windows box is a local SMTP service that sites relay through on 127.0.0.1, so the application configuration is just localhost on port 25 with no credentials.

MailEnable Standard Edition is free.  Download it from MailEnable Downloads. MailEnable's own guidance is at What are the best relay settings to use? and the manual is at MailEnable Standard Guide.

Two things get a server's IP onto a blocklist, and both are avoidable.  The first is being an open relay, where spammers find the box and push volume through it, and the second is sending with broken or missing reverse DNS, which some receivers treat as a spam signal in its own right.  Once you are listed, getting off is slow, it affects every domain sending through that IP, and a second listing is harder to clear than the first.  Fix both before the first site goes live rather than after the complaints start.

The principle for the whole section is that this server sends and does not receive.  Nothing on the internet needs to reach it on an email port.  Keep TCP 25, 465, 587, 110, 143, 993 and 995 closed inbound at the firewall and let only outbound 25 through, which is all a relay needs.  If you followed the firewall section you already have this, since only 80, 443 and 3389 were opened, but check rather than assume, because installing a mail server is exactly the sort of thing that adds firewall rules on your behalf.  Open Windows Defender Firewall with Advanced Security, sort Inbound Rules by Group, and disable anything MailEnable created.

Lock the relay to 127.0.0.1

This is the part to get right.  Nothing except the server itself should be able to send, and the relay settings are what enforce that.

Open the MailEnable Administration console and expand Servers, localhost, Connectors.  Right-click SMTP, choose Properties, then the Relay tab.

Tick Allow relay for privileged IP ranges and leave every other relay option unticked.  Click the IP address ranges button and delete every entry that is there, then add a single range with both the from and to addresses set to 127.0.0.1.  MailEnable adds the server's own public IP addresses to this list during installation, and leaving them in place means anything that can reach the box on port 25 can relay through it.

Leave Allow relay for authenticated senders unticked, since you are not offering SMTP to anyone and there is nothing to authenticate.  Leave Allow relay for local sender addresses unticked too, because it grants relay based on the From address, which is trivially spoofed and is exactly how open relays get abused.

On the Inbound tab, change the binding from all available addresses to 127.0.0.1 only, so the service is not listening on the public interface at all.  That is belt and braces on top of the relay restriction, and it means a misconfiguration in one place does not expose you.

Then verify from outside the network that nothing answers on port 25.  A telnet or port scan from another machine is enough.  Testing from the server itself proves nothing, because 127.0.0.1 is precisely what you have just allowed.

Set the server hostname and rDNS

Receiving mail servers check that the sending IP has a reverse DNS record, and that the hostname it returns resolves forward to the same IP.  If it does not, Microsoft in particular will reject or junk your mail regardless of everything else you configure, and some blocklists will list the IP on that basis alone.  This is the single biggest deliverability item on a new box, and it is easy to miss because nothing on the server tells you it is wrong.

The PTR record lives in the reverse zone for the IP address, which you do not control.  It has to be set by whoever owns the IP, meaning your hosting provider, usually through a control panel field or a support ticket.  Set it to a hostname you own, such as vps01.example.com.

Then create a matching forward A record for that hostname pointing back at the same IP.  A PTR that resolves to a name which does not resolve back is only half the job.

While you are at it, rename the server itself to match. A fresh Windows install has a generated name like WIN-JJM1I0UUNBV, and MailEnable builds its HELO from the machine name and the configured domain, so leaving the default gives you something like WIN-JJM1I0UUNBV.example.com announced to every server you talk to.  Rename the machine to vps01 under Settings, System, About, Rename this PC, and reboot.

Configure the outbound HELO

Once the hostname is right, make sure MailEnable is announcing it.  In the same SMTP connector properties, go to the General tab and set the local domain name to your mail domain, so the HELO resolves to the full hostname matching your PTR.

Restart the MailEnable SMTP service afterwards.  It reads this at startup and caches it, so the change does nothing until you do.

Check the Outbound tab as well.  MailEnable can use a different value for the outbound EHLO than for the inbound banner, and if the General tab change does not take effect this is usually why.

If your provider blocks outbound TCP 25, which many do, configure a smart host on the Outbound tab pointing at their relay or a transactional service instead.  In that case the HELO matters less, because you are no longer delivering directly.

Verify it

To see the banner without sending anything, which is what confirms the hostname change landed:

(New-Object Net.Sockets.TcpClient("127.0.0.1",25)).GetStream() | % { (New-Object IO.StreamReader($_)).ReadLine() }

That should return a 220 line with your chosen hostname rather than the Windows-generated machine name.

To send a test message:

Send-MailMessage -To "you@example.com" -From "postmaster@example.com" -Subject "SMTP test $(Get-Date -f 'HH:mm:ss')" -Body "Test from $env:COMPUTERNAME" -SmtpServer "127.0.0.1" -Port 25

Send-MailMessage is deprecated but still present in Windows PowerShell 5.1 and is fine for a one-off check.

Send that to a Gmail address and open the message with Show Original.  That gives you SPF, DKIM and the reverse DNS result in one view, which is faster than testing each separately.

DNS records on the sending domain

A correctly locked-down relay with good rDNS will still land in spam if the sending domain's DNS is not right.  Each domain sending through the server needs an SPF record authorising the server's IP, a DKIM selector record with MailEnable configured to sign for that domain, and a DMARC record.

DKIM is the one people leave until last and it is worth doing properly. DMARC can pass on SPF alignment alone, but that breaks the moment a message is forwarded, and Gmail treats SPF-only authentication less favourably than both passing.  If any of your domains publish a DMARC policy stronger than p=none, do those first.

Install the utility tools

7-Zip from 7-zip.org, because Windows handles zip and nothing else.

Notepad++ from notepad-plus-plus.org.  Worth having over Notepad because it shows line endings and encoding and will not silently add a BOM that breaks something, and it opens large log files without falling over.

Beyond Compare from Scooter Software, which needs a licence and is worth paying for.  When a deployment has gone sideways, comparing the release folder against what is on the server with a filter on .dll files saves a lot of guessing.

Make IIS depend on SQL Server

On a box where both are on the same machine there is a race at boot.  Both services start automatically, IIS can win, and your sites start before the database is accepting connections.  You get startup failures, a Boot Failed page, or an application pool that has already given up by the time SQL is ready.

There is no UI for service dependencies, so this one has to be done from an elevated command prompt.  First check what W3SVC currently depends on, because the command replaces the list rather than adding to it:

sc.exe qc W3SVC

It depends on HTTP and WAS by default, and W3SVC needs both to function, so preserve them:

sc.exe config W3SVC depend= HTTP/WAS/MSSQL$SQLEXPRESS

The space after depend= is required and entries are separated by forward slashes.  From PowerShell rather than cmd, wrap the value in single quotes so the $ is not treated as a variable.

Run sc.exe qc W3SVC again to confirm, then reboot and check the sites come up cleanly.  You can also see the result in the Services console under the Dependencies tab of the World Wide Web Publishing Service.

One consequence: stopping SQL Server will now stop IIS too, which is worth remembering before you restart the engine on a live box.

Configure antivirus exclusions

Windows Server 2025 includes Microsoft Defender Antivirus enabled by default and you should leave it on.  The question is what to exclude so it does not cripple performance.

IIS and Umbraco constantly create, read and delete small files in a handful of directories.  Defender scans every operation, MsMpEng.exe competes with w3wp.exe for CPU, and in the worst case the scanner holds a lock during compilation and the application returns a 500.  If you have ever seen Examine index corruption with no obvious cause, this is a likely culprit.

Defender applies automatic exclusions for installed server roles including Web Server, but those cover real-time protection only and do not include your own site paths.

Be deliberate about what you exclude.  Microsoft has revised its Exchange Server guidance to remove previously recommended exclusions on the Temporary ASP.NET Files and inetsrv folders, on the basis that excluding them prevents detection of IIS webshells and backdoor modules.  The same principle applies here.  Exclude the temp and log directories where the performance cost is real, and do not blanket-exclude site content roots.  Microsoft's ASP.NET guidance is at Exclude folders from antivirus scanning, and there is separate guidance for SQL Server.

Server-level paths

These go in through the UI. Open Windows Security, choose Virus and Threat Protection, click Manage settings under Virus and threat protection settings, scroll to Exclusions and click Add or remove exclusions.  Add each of the following as a Folder:

C:\inetpub\logs
C:\inetpub\temp
C:\Windows\Microsoft.NET\Framework\v4.0.30319\Temporary ASP.NET Files
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files

Per-site paths

Doing these by hand on a box with a dozen sites is the sort of job that gets half done, so I use a script.  Save it as AddExclusions.ps1 in a permanent location such as C:\Tools\Scripts, because it writes its log alongside itself.

#Requires -RunAsAdministrator

<#
.SYNOPSIS
    Scans the websites root for matching Umbraco paths and adds AV exclusions.
    Limited to 5 levels deep for performance.
#>

param(
    [string]$RootPath = "C:\inetpub\wwwroot",
    [int]$MaxDepth = 5,
    [switch]$WhatIf
)

Write-Host "Scanning $RootPath for matching paths (max depth: $MaxDepth)..." -ForegroundColor Green
Write-Host "Started at: $(Get-Date -Format 'HH:mm:ss')" -ForegroundColor Yellow
Write-Host "WhatIf mode: $WhatIf" -ForegroundColor Yellow
Write-Host ""

if (-not (Test-Path $RootPath)) {
    Write-Host "Error: Path $RootPath does not exist!" -ForegroundColor Red
    exit 1
}

# Setup log file (same file every time, overwrite)
$logPath = Join-Path $PSScriptRoot "av-exclusions.log"
$logContent = @()
$logContent += "AV Exclusion Scan Log"
$logContent += "====================="
$logContent += "Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$logContent += "Root Path: $RootPath"
$logContent += "Max Depth: $MaxDepth"
$logContent += "WhatIf Mode: $WhatIf"
$logContent += ""

Write-Host "Reading existing AV exclusions..." -ForegroundColor Cyan
$existingExclusions = @()
try {
    $mpPrefs = Get-MpPreference
    $existingExclusions = $mpPrefs.ExclusionPath
    if ($null -eq $existingExclusions) {
        $existingExclusions = @()
    }
    Write-Host "Found $($existingExclusions.Count) existing exclusions" -ForegroundColor Gray
    $logContent += "Existing exclusions: $($existingExclusions.Count)"
}
catch {
    Write-Host "Warning: Could not read existing exclusions - $($_.Exception.Message)" -ForegroundColor Yellow
    $logContent += "Warning: Could not read existing exclusions"
}
$logContent += ""

$foundPaths = @()
$addedPaths = @()
$skippedPaths = @()
$failedPaths = @()
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

Write-Host "Searching for directories..." -ForegroundColor Cyan
$logContent += "DIRECTORY SCAN"
$logContent += "=============="

try {
    $allDirs = Get-ChildItem -Path $RootPath -Directory -Recurse -Depth $MaxDepth -ErrorAction SilentlyContinue

    Write-Host "Found $($allDirs.Count) total directories. Filtering for matches..." -ForegroundColor Cyan
    $logContent += "Total directories scanned: $($allDirs.Count)"
    $logContent += ""

    foreach ($dir in $allDirs) {
        $relativePath = $dir.FullName.Substring($RootPath.Length).TrimStart('\')

        $matched = $false
        $matchType = ""

        # Umbraco 8 and earlier
        if ($relativePath -like "*\App_Data\Temp") {
            $matched = $true
            $matchType = "Umbraco - App_Data\Temp"
        }
        elseif ($relativePath -like "*\App_Data\Logs") {
            $matched = $true
            $matchType = "Umbraco - App_Data\Logs"
        }
        elseif ($relativePath -like "*\App_Data\cache") {
            $matched = $true
            $matchType = "Umbraco - App_Data\cache"
        }
        elseif ($relativePath -like "*\App_Data\NuGetBackup") {
            $matched = $true
            $matchType = "Umbraco - App_Data\NuGetBackup"
        }
        # Umbraco 9 and later
        elseif ($relativePath -like "*\umbraco\Data") {
            $matched = $true
            $matchType = "Umbraco - umbraco\Data"
        }
        elseif ($relativePath -like "*\umbraco\Logs") {
            $matched = $true
            $matchType = "Umbraco - umbraco\Logs"
        }

        if ($matched) {
            $exclusionPath = $dir.FullName + "\*"

            if ($foundPaths -notcontains $exclusionPath) {
                $foundPaths += $exclusionPath

                $alreadyExcluded = $false
                foreach ($existing in $existingExclusions) {
                    if ($existing -eq $exclusionPath -or $existing -eq $dir.FullName) {
                        $alreadyExcluded = $true
                        break
                    }
                }

                if ($alreadyExcluded) {
                    Write-Host "  Already excluded: $exclusionPath" -ForegroundColor DarkGray
                    Write-Host "                    Type: $matchType" -ForegroundColor DarkGray
                    $skippedPaths += $exclusionPath
                    $logContent += "[SKIP] $exclusionPath ($matchType)"
                }
                elseif ($WhatIf) {
                    Write-Host "  [WHATIF] Would add: $exclusionPath" -ForegroundColor Yellow
                    Write-Host "                      Type: $matchType" -ForegroundColor Gray
                    $logContent += "[WHATIF] $exclusionPath ($matchType)"
                }
                else {
                    try {
                        Add-MpPreference -ExclusionPath $exclusionPath -ErrorAction Stop
                        Write-Host "  Added: $exclusionPath" -ForegroundColor Green
                        Write-Host "         Type: $matchType" -ForegroundColor Gray
                        $addedPaths += $exclusionPath
                        $logContent += "[ADDED] $exclusionPath ($matchType)"
                    }
                    catch {
                        Write-Host "  Failed: $exclusionPath" -ForegroundColor Red
                        Write-Host "          Error: $($_.Exception.Message)" -ForegroundColor Red
                        $failedPaths += $exclusionPath
                        $logContent += "[FAILED] $exclusionPath - $($_.Exception.Message)"
                    }
                }
            }
        }
    }
}
catch {
    Write-Host "Error scanning directories: $($_.Exception.Message)" -ForegroundColor Red
    $logContent += "ERROR: $($_.Exception.Message)"
}

$stopwatch.Stop()

Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "SUMMARY" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Scan completed at: $(Get-Date -Format 'HH:mm:ss')" -ForegroundColor Yellow
Write-Host "Time taken: $($stopwatch.Elapsed.TotalSeconds) seconds" -ForegroundColor Yellow
Write-Host "Max depth: $MaxDepth levels" -ForegroundColor Yellow
Write-Host "Total directories scanned: $($allDirs.Count)" -ForegroundColor White
Write-Host "Matching paths found: $($foundPaths.Count)" -ForegroundColor White
Write-Host "Already excluded: $($skippedPaths.Count)" -ForegroundColor DarkGray
if ($WhatIf) {
    Write-Host "Would add: $($foundPaths.Count - $skippedPaths.Count)" -ForegroundColor Yellow
} else {
    Write-Host "Successfully added: $($addedPaths.Count)" -ForegroundColor Green
}
if ($failedPaths.Count -gt 0) {
    Write-Host "Failed: $($failedPaths.Count)" -ForegroundColor Red
}
Write-Host ""

$logContent += ""
$logContent += "SUMMARY"
$logContent += "======="
$logContent += "Scan completed: $(Get-Date -Format 'HH:mm:ss')"
$logContent += "Time taken: $($stopwatch.Elapsed.TotalSeconds) seconds"
$logContent += "Total directories scanned: $($allDirs.Count)"
$logContent += "Matching paths found: $($foundPaths.Count)"
$logContent += "Already excluded: $($skippedPaths.Count)"
if ($WhatIf) {
    $logContent += "Would add: $($foundPaths.Count - $skippedPaths.Count)"
} else {
    $logContent += "Successfully added: $($addedPaths.Count)"
}
$logContent += "Failed: $($failedPaths.Count)"
$logContent += ""

Write-Host "Breakdown by type:" -ForegroundColor Cyan
$umbracoTemp     = ($foundPaths | Where-Object { $_ -like "*\App_Data\Temp\*" }).Count
$umbracoLogs     = ($foundPaths | Where-Object { $_ -like "*\App_Data\Logs\*" }).Count
$umbracoCache    = ($foundPaths | Where-Object { $_ -like "*\App_Data\cache\*" }).Count
$umbracoNuGet    = ($foundPaths | Where-Object { $_ -like "*\App_Data\NuGetBackup\*" }).Count
$umbracoData     = ($foundPaths | Where-Object { $_ -like "*\umbraco\Data\*" }).Count
$umbracoDataLogs = ($foundPaths | Where-Object { $_ -like "*\umbraco\Logs\*" }).Count

Write-Host "  App_Data\Temp: $umbracoTemp" -ForegroundColor White
Write-Host "  App_Data\Logs: $umbracoLogs" -ForegroundColor White
Write-Host "  App_Data\cache: $umbracoCache" -ForegroundColor White
Write-Host "  App_Data\NuGetBackup: $umbracoNuGet" -ForegroundColor White
Write-Host "  umbraco\Data: $umbracoData" -ForegroundColor White
Write-Host "  umbraco\Logs: $umbracoDataLogs" -ForegroundColor White

$logContent += "BREAKDOWN BY TYPE"
$logContent += "================="
$logContent += "App_Data\Temp: $umbracoTemp"
$logContent += "App_Data\Logs: $umbracoLogs"
$logContent += "App_Data\cache: $umbracoCache"
$logContent += "App_Data\NuGetBackup: $umbracoNuGet"
$logContent += "umbraco\Data: $umbracoData"
$logContent += "umbraco\Logs: $umbracoDataLogs"
$logContent += ""

Write-Host ""
Write-Host "All matching paths:" -ForegroundColor Cyan
$foundPaths | Sort-Object | ForEach-Object { Write-Host "  $_" -ForegroundColor Gray }

$logContent += "ALL MATCHING PATHS"
$logContent += "=================="
$foundPaths | Sort-Object | ForEach-Object { $logContent += $_ }

$logContent | Out-File -FilePath $logPath -Encoding UTF8 -Force
Write-Host ""
Write-Host "Log saved to: $logPath" -ForegroundColor Green

if ($WhatIf) {
    Write-Host ""
    Write-Host "Run without -WhatIf to apply exclusions" -ForegroundColor Yellow
}

Open PowerShell as Administrator and do a dry run first.  The -WhatIf switch scans and reports without touching Defender's configuration:

.\AddExclusions.ps1 -WhatIf

If the output looks right, run it without the switch to apply.  If your sites live somewhere other than C:\inetpub\wwwroot, use -RootPath "D:\Sites", and if your layout is nested deeper than five levels, raise -MaxDepth.  Depth is what controls how long the scan takes, which is why it is capped.

The script is safe to run repeatedly, because it reads the current exclusion list first and skips anything already there.  Every run overwrites av-exclusions.log next to the script, which is the record of what was excluded and why.  When I come back in a year and wonder why a folder is excluded, the log tells me.

The patterns cover both generations of Umbraco.  For version 8 and earlier, App_Data\Temp holds the Examine indexes and NuCache files, App_Data\Logs holds the Serilog output, App_Data\cache holds runtime caches, and App_Data\NuGetBackup is left behind by package installs.  For version 9 and later the equivalents are umbraco\Data, which is also where a SQLite file sits if the site uses one, and umbraco\Logs.

What is deliberately absent is the site root, the bin folder and the media folders.  Those are where a webshell would land if someone found an upload vulnerability, and you want Defender looking at them.

One operational note: the script only excludes folders that exist when it runs, and a freshly deployed site may not create its Temp or Logs folders until it has served a request.  Run it after deploying any new site, or put it in a weekly scheduled task running as SYSTEM.

Set up backups

A server without a tested backup is a server you are going to lose.  Daily is the minimum, and there are two separate things to cover.

The server itself.  Most providers offer snapshot or image backups, and if yours does, use it.  It is the fastest route back from a failed update or a compromise.  Otherwise add the Windows Server Backup feature through Add roles and features and configure a daily backup schedule through wbadmin.msc to a separate volume or network target.  Backing up to the same disk you are protecting is not a backup.

The databases.  This needs attention on Express, because there is no SQL Server Agent to schedule jobs with.  Use a Windows scheduled task instead, calling sqlcmd:

sqlcmd -S .\SQLEXPRESS -Q "BACKUP DATABASE [MySite] TO DISK='D:\Backups\MySite.bak' WITH INIT, COMPRESSION, CHECKSUM"

For more than a couple of databases, use Ola Hallengren's Maintenance Solution instead.  It handles full, differential and log backups, index maintenance and integrity checks, it works on Express via scheduled tasks, and it is free.

Whichever route you take, backups must leave the server, and you must restore one occasionally to prove it works.  An untested backup is a hypothesis.

Per-site configuration

Everything above is once per server.  This section is once per site.

Create the application pool

Use a dedicated pool per site rather than sharing one.  Isolation means a leak in one site does not take down the others, and it gives you a distinct identity to grant permissions to.

In IIS Manager, right-click Application Pools and choose Add Application Pool.  Name it after the site, and set the .NET CLR version to No Managed Code for any modern .NET site.  ASP.NET Core runs in its own process and boots CoreCLR itself, so it does not need the desktop CLR loaded.

Set memory limits so the pool recycles

Left alone, a pool will grow until the server runs out of memory, and then everything suffers rather than just the site with the problem.  A private memory limit means the offending pool recycles and the rest of the server carries on.

Select the pool, choose Advanced Settings from the Actions pane, and under Recycling set Private Memory Limit (KB).  For 1GB that is 1048576.

What the right number is depends on the site.  An Umbraco site with a large content tree and big Examine indexes will legitimately use more than a brochure site.  Watch the working set under normal load for a week and set the limit above that with headroom.  A pool that recycles every twenty minutes is worse than one using a lot of memory, because every recycle is a cold start.

Two related settings on the same screen.  Set Idle Time-out (minutes) to 0, because the default of 20 means the site shuts down after a quiet period and the next visitor waits for a cold start.  And set Regular Time Interval (minutes) to 0, then add a Specific Time under Recycling so the daily recycle happens at 4am rather than 29 hours after whenever you last deployed.

Decide on 32-bit mode

Setting Enable 32-Bit Applications to True in Advanced Settings runs the worker process as 32-bit, which meaningfully reduces the memory footprint.  On a box hosting several small sites that adds up.

The trade-off is a smaller virtual address space and a smaller IIS stack.  Microsoft's guidance is to deploy 32-bit unless the application needs the larger address space, needs the larger stack, or has 64-bit native dependencies.  For a typical content-managed Umbraco site none of those apply.  For big Examine indexes, heavy image processing or a large Commerce catalogue, they may.

A 32-bit pool needs the x86 runtime present.  The Hosting Bundle installs it by default, so unless you used OPT_NO_X86=1 you are fine.  If the pool starts and immediately fails with a 500.30 or 500.31, check dotnet --list-runtimes first.

Set the folder permissions

Umbraco needs write access to specific folders, and the account that needs it is the application pool identity.  With the default ApplicationPoolIdentity setting that account is IIS APPPOOL\<pool name>.  It is a virtual account, it does not appear in the local users list, and you can grant it permissions directly.

In Explorer, right-click the folder, choose Properties, then the Security tab, then Edit and Add.  Type IIS APPPOOL\example.com into the object name box, click Check Names, and grant Modify.  The permissions inherit downwards by default.

The folders that need Modify are App_Plugins, umbraco, Views, wwwroot\css, wwwroot\scripts, wwwroot\media and wwwroot\umbraco.  Note that App_Plugins and wwwroot\umbraco are used by packages and are not part of your project by default, so they may not exist until you install something.

appsettings.json also needs write access, but only during installation when Umbraco sets the connection string and a global identifier.  Set it back to read-only afterwards, which is a small but real improvement on anything public-facing.

Separately, IUSR and the IIS_IUSRS group only need read access.  Granting them Modify is a common and unnecessary over-permission.

If you would rather script it than click through seven folders:

$sitePath = "C:\inetpub\wwwroot\example.com"
$appPool  = "IIS APPPOOL\example.com"

$folders = @("App_Plugins", "umbraco", "Views", "wwwroot\css",
             "wwwroot\scripts", "wwwroot\media", "wwwroot\umbraco")

foreach ($folder in $folders) {
    $target = Join-Path $sitePath $folder
    if (-not (Test-Path $target)) { New-Item -Path $target -ItemType Directory | Out-Null }
    icacls $target /grant "${appPool}:(OI)(CI)M"
}

The full table is at File and Folder Permissions.

Set up the database login

Rather than a SQL login with a password in the connection string, use the application pool identity.  A pool running as ApplicationPoolIdentity connecting to SQL Server on the same machine authenticates as IIS APPPOOL\<pool name>, so you can create a login for it directly.

In SSMS, expand Security, right-click Logins and choose New Login.  Enter IIS APPPOOL\example.com as the login name, leave Windows authentication selected, and click Check Names to confirm it resolves.  Go to the User Mapping page, tick the site's database, and tick db_owner in the role membership list below.

Umbraco needs to read and write tables for normal operation and to create schema during installs and upgrades, which is why db_owner.  If you want tighter permissions for normal running, db_datareader and db_datawriter are enough, but you will need db_owner back temporarily for any upgrade with migrations.  On a server where you do the upgrades yourself, leaving it as db_owner is the pragmatic choice.

The connection string then has no credentials in it:

{
  "ConnectionStrings": {
    "umbracoDbDSN": "Server=.\\SQLEXPRESS;Database=example_com;Integrated Security=true;TrustServerCertificate=true;",
    "umbracoDbDSN_ProviderName": "Microsoft.Data.SqlClient"
  }
}

TrustServerCertificate=true is there because recent versions of Microsoft.Data.SqlClient encrypt by default and a default Express install uses a self-signed certificate.  On a local connection that is acceptable.  Across a network it is not, and you should install a proper certificate instead.

Final checks

Reboot the server and confirm everything comes back on its own in the right order.  This is what tests the service dependency and it is the step people skip.

Check the sites respond on HTTP and HTTPS, and run the certificate through SSL Labs.

Log into the backoffice and use Preview, because Preview exercises the SignalR connection and tells you the WebSocket feature is working.

Send a test email to a Gmail address and check SPF, DKIM and rDNS under Show Original, then confirm from outside that SMTP is closed.

Scan the box from outside and confirm only 80, 443 and 3389 respond, that 3389 only responds to you, and that nothing answers on 25 or any other email port.

Take a backup, restore it somewhere else, and confirm it works.


I build and maintain Umbraco sites from Kent, and have been doing it since 2013. If you have a server that needs looking at, or an Umbraco project that needs a hand, get in touch.