
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
Daily Report of Off-Hours Logon Failures Across the Domain
This guide builds a scheduled PowerShell job that reads the Security event log on every domain controller, pulls failed-logon events (Event ID 4625 ) from the previous day, keeps only the ones that happened outside your defined business hours (and optionally on weekends), and writes them to a dated CSV. Optionally it emails the file. It is a reporting job — it reads logs and writes a report file. It does not change Active Directory, disable accounts, or touch any policy.
Find AD Security Groups With Cross-Domain Members for Cleanup
In a multi-domain forest, group membership sprawls across domain boundaries over the years: a universal or domain-local group in corp ends up holding users from emea , or Foreign Security Principals (FSPs) from a trusted external forest that nobody remembers adding. Before you clean any of that up, you need an accurate inventory. This guide gives you a read-only report of every group in one domain that contains members from another domain, plus a carefully gated way to remove the ones you've decided to drop.
Automate a Weekly NTFS Share Permission Drift Audit
Share permissions drift. Someone grants a contractor "just temporary" access, a helpdesk ticket adds a group to a folder, an app installer rewrites an ACL — and six months later nobody remembers why Everyone can write to the finance share. This guide builds a simple, read-only baseline of your NTFS share ACLs and a weekly job that tells you exactly what has changed since.
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.




