
Automatically Remove Stale User Profiles on an RDS Host
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:\Users — not 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—$truefor system accounts (LocalSystem, NetworkService, and so on). Never delete these.Loaded—$truewhen the profile is currently in use. We skip loaded profiles so we never yank a profile from an active session.LastUseTime— aDateTime(when read viaGet-CimInstance; the olderGet-WmiObjectreturns 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.
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.
Related guides
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.
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.
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.
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.




