Shore Up
An office cleaner pulling dusty, cobweb-covered name folders out of a row of lockers marked with old dates and dropping them in a bin, while the freshly-labelled lockers next to them are left untouched.
Windows

Automatically Remove Stale User Profiles on an RDS Host

Ketan Aagja8 min read
No ratings yet

On a busy Remote Desktop Session Host, local profiles pile up fast — every user who ever logged in leaves a folder under C:\Users, and a system drive fills quietly until logons start failing. This guide sets up an unattended, standard-supported cleanup of profiles that haven't been used in N days.

I assume Windows Server 2019 or 2022 running the RDS Session Host role, with Windows PowerShell 5.1 (the in-box version). I assume local, roaming, or mandatory profiles stored under C:\Usersnot FSLogix profile containers or User Profile Disks. If you use FSLogix or UPD, stop here: those are managed by their own mechanisms and this method does not apply.

Before you run this

What it does: it enumerates local user profiles via the Win32_UserProfile class, finds those whose LastUseTime is older than a threshold you set, and deletes them properly — folder and the associated registry entry under ProfileList. Deleting the profile folder by hand leaves orphaned registry keys behind; the CIM delete method removes both, which is why we use it.

Privileges: this must run in an elevated PowerShell session (Run as administrator), and the scheduled task must run as SYSTEM or a local administrator. Deleting profiles touches C:\Users and HKLM.

This is destructive and irreversible. Deleting a profile removes that user's local documents, desktop, cached data, and per-user registry hive (NTUSER.DAT). There is no built-in undo. If the profile is roaming, the server copy of unsynced data is gone. Treat this with the caution you'd give any bulk delete.

Test first. Read the script. Run it on a lab RDS host or a spare VM before it ever touches a production server, and on the real box run it in preview mode (-WhatIf) first and review the list of profiles it would delete. LastUseTime is a real property but it is not a perfect record of "last interactive logon" in every scenario — verify the candidates look right before you commit.

A cleanup run is also a good moment to confirm you have a current backup or a VSS snapshot / VM checkpoint of the system drive, since that is your only recovery path if you delete something you shouldn't have.

The safe approach: the built-in filters

Win32_UserProfile gives us three properties that keep us out of trouble:

  • Special$true for system accounts (LocalSystem, NetworkService, and so on). Never delete these.
  • Loaded$true when the profile is currently in use. We skip loaded profiles so we never yank a profile from an active session.
  • LastUseTime — a DateTime (when read via Get-CimInstance; the older Get-WmiObject returns a string that needs conversion, which is one reason we use CIM).

The script

Save this as C:\Scripts\Remove-StaleProfiles.ps1. Change the default -Days and -LogFile to suit you. It is safe by default: because it uses SupportsShouldProcess, running it with -WhatIf previews and deletes nothing.

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [int]$Days    = 30,                              # profiles idle longer than this are candidates
    [string]$LogFile = 'C:\Scripts\ProfileCleanup.log'
)

$cutoff = (Get-Date).AddDays(-$Days)
"{0}  Run start. Cutoff = {1} (Days = {2})" -f (Get-Date), $cutoff, $Days |
    Tee-Object -FilePath $LogFile -Append

# Select only non-system, not-currently-loaded profiles older than the cutoff.
$stale = Get-CimInstance -ClassName Win32_UserProfile | Where-Object {
    -not $_.Special -and                            # skip system/service profiles
    -not $_.Loaded  -and                            # skip profiles in active use
    $null -ne $_.LastUseTime -and                   # skip profiles with no usable timestamp
    $_.LastUseTime -lt $cutoff
}

foreach ($p in $stale) {
    if ($PSCmdlet.ShouldProcess($p.LocalPath, "Delete profile (last used $($p.LastUseTime))")) {
        try {
            Remove-CimInstance -InputObject $p -ErrorAction Stop   # removes folder AND ProfileList entry
            "{0}  DELETED  {1}  (lastuse {2})" -f (Get-Date), $p.LocalPath, $p.LastUseTime |
                Tee-Object -FilePath $LogFile -Append
        }
        catch {
            "{0}  ERROR    {1}  {2}" -f (Get-Date), $p.LocalPath, $_.Exception.Message |
                Tee-Object -FilePath $LogFile -Append
        }
    }
}

"{0}  Run complete." -f (Get-Date) | Tee-Object -FilePath $LogFile -Append

Replace C:\Scripts\... with wherever you keep scripts and logs. -Days 30 is a placeholder threshold — pick a number that matches how long a user can be away before you're comfortable wiping their local profile.

Preview, then commit

From an elevated prompt, always preview first:

# Shows exactly which profiles WOULD be deleted, changes nothing
C:\Scripts\Remove-StaleProfiles.ps1 -Days 30 -WhatIf

Read that list. When you're satisfied, run it for real:

C:\Scripts\Remove-StaleProfiles.ps1 -Days 30

Scheduling it

Once you trust the script, run it on a schedule as SYSTEM. These ScheduledTasks module cmdlets are standard on Server 2019/2022. Run this once, elevated:

$action  = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Remove-StaleProfiles.ps1" -Days 30'

$trigger = New-ScheduledTaskTrigger -Daily -At '03:00'          # off-hours, no active sessions

$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
    -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName 'Remove Stale RDS Profiles' `
    -Action $action -Trigger $trigger -Principal $principal `
    -Description 'Deletes local user profiles idle longer than 30 days'

Note there is no -WhatIf in the scheduled argument — the task performs real deletions. That's the point of previewing manually first. Schedule it for a maintenance window when no one is logged on, so Loaded profiles (which we skip anyway) are minimal.

The native alternative

Microsoft ships a Group Policy setting that does much the same thing without a script:

Computer Configuration → Policies → Administrative Templates → System → User Profiles → "Delete user profiles older than a specified number of days on system restart."

It runs at restart rather than on a schedule and uses its own "last used" determination, which can differ slightly from LastUseTime. If you already reboot session hosts regularly and don't need logging or dry-run control, this is the lower-maintenance option. The PowerShell approach wins when you want a log, a preview, and cleanup without a reboot.

Verify it worked

Check the log first:

Get-Content C:\Scripts\ProfileCleanup.log -Tail 40

Then confirm the profiles are actually gone — both from CIM and from disk:

# Remaining non-system profiles and their last-use dates
Get-CimInstance Win32_UserProfile |
    Where-Object { -not $_.Special } |
    Select-Object LocalPath, LastUseTime, Loaded |
    Sort-Object LastUseTime

# The folders that remain
Get-ChildItem C:\Users

Because we used the CIM delete method, the matching entries under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList are removed too — you can spot-check that key in regedit if you want to be sure no orphans remain.

Undo

There is no rollback for a deleted profile. If you deleted one you needed, your options are recovery from your backup, from a VSS previous version of the folder if shadow copies are enabled, or from a VM checkpoint taken before the run. This is exactly why the preview step and a current backup are non-negotiable.

For exact property definitions and method behaviour, see the Microsoft Learn documentation for the Win32_UserProfile class, the Remove-CimInstance cmdlet, and Register-ScheduledTask before adapting any of this to your environment.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Detect and Quarantine Suspicious File Extensions in Shared Folders

This guide builds a PowerShell script that scans an SMB shared folder for files whose extensions are commonly used to carry malware ( .exe , .scr , .js , .vbs , .bat , and so on), and moves any it finds into a locked-down quarantine folder outside the share, writing every action to a log. It is a crude, extension-based screen — a tripwire, not an antivirus engine. It does not inspect file contents and will not catch a malicious .docm or a renamed payload. Treat it as one layer, not the layer.

9 min read

Find and Merge Duplicate Active Directory User Accounts

Before I start, one honesty note that shapes this whole guide: Active Directory has no merge operation . There is no Merge-ADUser cmdlet, and there never was. When people say "merge duplicate accounts," what they actually need is a repeatable process to find the duplicates, decide which one survives , copy the things that matter (mainly group memberships) onto the survivor , and then retire the other . That is what this guide automates. The detection half is safe and read-only. The consolidation half changes and can delete accounts, so it is gated hard.

9 min read

Automate DHCP Lease Reporting on Windows Server

This guide builds a read-only PowerShell script that enumerates your DHCP scopes, pulls the current IPv4 leases from each, writes them to a timestamped CSV, and (optionally) emails the file. It does not change any DHCP configuration — no scopes, reservations, options, or leases are created, modified, or deleted. The worst it can do is fill a disk with CSVs if you never prune them.

9 min read

Automate a Daily Failed-Logon (4625) Report with PowerShell

This guide builds a scheduled PowerShell job that reads Event ID 4625 (failed logon) from the Windows Security log for the last 24 hours and writes them to a dated HTML report. It is read-only — it queries the event log and creates a report file. It does not change auditing policy, delete events, or touch accounts.

9 min read