---
title: "A Folder Rename Failed With Access Denied Because of Services I Had Forgotten Were Running"
canonical: https://dxdev.com/blog/2026-05-16_migration-blocked-by-ghosts/
datePublished: 2026-05-16
---
I typed the rename command that was supposed to close out a drive migration: the old project-drive root to a dated backup name. Windows said no.

This was not a code problem. The migration's sweep commit had already landed the environment variable that gates the projects root, and every consumer in the codebase had been updated to read from it. The rename was supposed to be the mechanical last step, the confirmation that the migration was done. Instead I got Access Denied.

## the code was already clean

The migration started as a straightforward path move. My project root had lived on one drive since I built this machine, and I wanted it on a faster one. The pattern was simple: introduce a single environment variable, sweep every path consumer to read from it, then rename the old root as a dated archive so nothing could drift back.

The sweep commit did that work. After it landed, I grepped the repo for every hardcoded reference to the old path I could find. Nothing came back.

I went to rename the directory. Windows said no.

My first thought was that I had missed a file reference somewhere. That was wrong.

## what access denied actually means here

On Windows, Access Denied on a directory rename does not mean permissions. It means another process still has a handle open on the path. The filesystem is not yours to move while something else is reading it.

The obvious suspects went first. My own coding session was running inside the old root at that exact moment, asking it to help finish the migration, which meant the tool investigating the problem was itself holding a handle on the path I needed to rename. I closed it. The local IIS web server was also serving sites out of the old root, so I stopped that too, an elevated service stop, not a click in a control panel.

Still denied. I handed the machine to a fresh session with nothing else open in that directory and kept going.

## the search that came back clean, and why

I asked Windows for every service whose reported path pointed at the old root. Nothing came back, and for a few minutes I believed that meant no service was involved. That belief was wrong, and finding out why took another layer of digging.

A handful of background processes on this machine run wrapped by NSSM, the Non-Sucking Service Manager. NSSM lets you run any executable as a proper Windows service, one that survives reboots and session closes regardless of what happens on the desktop. The catch is that the service control manager only ever sees NSSM's own path. It has no idea what program NSSM is actually running underneath, or where that program thinks its working directory is. A path search against the service list will never find it.

The real answer sat one registry key deeper, under each wrapped service's own parameters. That is where the actual working directory lives, and it does not show up in any tool that only asks the service control manager.

## the services I had forgotten

Two of the NSSM-wrapped services on this machine, a reverse proxy and a small app server, had working directories pointing straight into the old root. I had installed both to keep background tooling alive and had not thought about either one while planning the migration. Windows services hold an open handle to their working directory by default. There is no warning, no log entry, no error surface. The service just quietly owns the path.

I stopped both, checked the registry again, found nothing left pointing at the old root, and the rename went through.

## a second ghost, from somewhere else entirely

A few days after the rename went through, I found a scheduled task on the same machine pointed at a path that no longer existed. It had nothing to do with the drive migration. A separate reorg, done the day before the migration for unrelated reasons, had moved the folder that task's stored arguments pointed at, and nobody had gone back to update it. I only found it because I was building something else at the time and happened to notice the task's log had stopped updating.

It is a different failure than the one Access Denied surfaced, but it rhymes: a scheduled task's stored arguments are set once through a GUI or a one-off command and never committed anywhere, so nothing about them shows up in a codebase search, and nothing announces when they go stale. This one did not come from the migration. It came from the same kind of blind spot.

## the inventory that was missing

The real output of this migration was not the environment variable. It was a workstation inventory I can reference the next time anything needs to move: every wrapped service, its executable, its real working directory, and its log path. That inventory did not exist before this. It exists now because Access Denied forced me to build it.

A codebase grep finds hardcoded paths in tracked files. It does not find a service manager's idea of a working directory. That is a part of a workstation that does not version-control itself, and it does not show up until something that depends on it actually runs.

I do not know what else on this machine still points at an old path, migration or reorg. The scheduled task I found only surfaced because I happened to be looking at something adjacent to it. Whatever else is out there is waiting on the same kind of accident.

---
