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.

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

Monitor Free Disk Space and Email an Alert with PowerShell

This script reads the free space on the fixed local disks of the machine it runs on and emails you when any of them drops below a percentage you set. It is a lightweight stand-in for a monitoring platform — good for a handful of servers, not a replacement for one across a fleet.

8 min read

Automate a Nightly Robocopy Mirror to a NAS with Verification

This sets up a nightly one-way mirror of a local folder to a NAS SMB share using robocopy /MIR , followed by a second pass that lists any remaining differences as a verification step, all logged to a timestamped file and driven by a Scheduled Task.

9 min read