Loosely Decoupled
Guide

See What's on an Old Azure VM Disk Before You Delete It

You get the old disk’s top-level folders with the size and last-modified date of each, so you can decide what to keep before the disk is gone.

A patent-style drawing of an old strongbox held back from a disposal chute by a clamp, beside an open copy of it whose compartments sit under a cyan magnifying lens on a jointed arm.
On this page

TL;DR

Take a snapshot of the old OS disk before you delete anything. Create a disk from the snapshot in the same zone as a VM you already run, attach it to that VM as a data disk, and start the VM. Bring the disk online and list its folders with az vm run-command invoke. You need no RDP session and no download. The variables are set in the guide’s first step, and inspect.ps1 is the script in its mount step.

Bash
az snapshot create --resource-group "$ARCHIVE_GROUP" --name "$SNAPSHOT_NAME" \
  --source "$OLD_DISK_ID" --incremental true --sku Standard_LRS
SNAPSHOT_ID=$(az snapshot show --resource-group "$ARCHIVE_GROUP" \
  --name "$SNAPSHOT_NAME" --query id --output tsv)
az disk create --resource-group "$VM_GROUP" --name "$INSPECT_DISK" \
  --source "$SNAPSHOT_ID" --zone "$VM_ZONE" --sku Standard_LRS
az vm disk attach --resource-group "$VM_GROUP" --vm-name "$VM_NAME" \
  --name "$INSPECT_DISK" --lun 0
az vm start --resource-group "$VM_GROUP" --name "$VM_NAME"
az vm run-command invoke --resource-group "$VM_GROUP" --name "$VM_NAME" \
  --command-id RunPowerShellScript --scripts @inspect.ps1

Tested with

Azure CLI
2.90.0 on macOS 27.0.1
Guest VM
Windows Server 2019 Datacenter
AWS CLI
2.38.1
Date
October 2026

I had an old resource group in Azure. It held a VM, a public IP, a network card and a 127 GB OS disk, and I couldn’t remember what was on the disk. The VM had been a Rust game server years ago. Since then, the game I host has changed to 7 Days to Die, on a different VM. I didn’t want to pay for the old disk, but I also didn’t want to delete a world save I might miss. So I asked Claude Code, with the Azure CLI signed in on my Mac, what was on the disk.

Set the names once

Every command in this guide reads its names from shell variables. In a terminal with the Azure CLI signed in, set them, replacing each placeholder:

Bash
OLD_DISK_ID="<resource-id-of-the-old-os-disk>"
ARCHIVE_GROUP="<new-resource-group-for-the-snapshot>"
SNAPSHOT_NAME="<snapshot-name>"
VM_GROUP="<resource-group-of-the-vm-you-run>"
VM_NAME="<vm-you-run>"
INSPECT_DISK="<temporary-disk-name>"

Snapshot the disk first

The snapshot is what lets you delete everything else right away. This creates a resource group for it, so it outlives the old VM’s group, and an incremental snapshot of the old OS disk:

Bash
az group create --name "$ARCHIVE_GROUP" --location "<region>"
az snapshot create --resource-group "$ARCHIVE_GROUP" --name "$SNAPSHOT_NAME" \
  --source "$OLD_DISK_ID" --incremental true --sku Standard_LRS

Use the old disk’s region for --location. An incremental snapshot is billed for its used size only (opens in a new tab), not for the disk’s provisioned size.

Do not wait for the snapshot’s completionPercent to confirm it finished. For mine, that field came back empty, and a loop polling it never ended. Prove the snapshot is readable instead. This grants read access for five minutes, requests the first 512 bytes, and revokes access:

Bash
SAS=$(az snapshot grant-access --resource-group "$ARCHIVE_GROUP" \
  --name "$SNAPSHOT_NAME" --duration-in-seconds 300 --access-level Read \
  --query accessSAS --output tsv)
curl --silent --output /dev/null --write-out '%{http_code}\n' \
  --range 0-511 "$SAS"
unset SAS
az snapshot revoke-access --resource-group "$ARCHIVE_GROUP" \
  --name "$SNAPSHOT_NAME"

206 means the snapshot is readable. It doesn’t prove that every file is in it; the listing later in this guide does. After a 206 you can delete the old VM’s resource group, but that deletes the old disk with it, and the snapshot becomes the only copy. To be certain first, wait until you have listed the files.

Create a disk in the same zone as the VM

A VM in an availability zone can attach only a disk in the same zone. Read the VM’s zone and the snapshot’s ID into variables:

Bash
VM_ZONE=$(az vm show --resource-group "$VM_GROUP" --name "$VM_NAME" \
  --query "zones[0]" --output tsv)
SNAPSHOT_ID=$(az snapshot show --resource-group "$ARCHIVE_GROUP" \
  --name "$SNAPSHOT_NAME" --query id --output tsv)

This creates a Standard HDD disk from the snapshot, which is enough for a disk you read once:

Bash
az disk create --resource-group "$VM_GROUP" --name "$INSPECT_DISK" \
  --source "$SNAPSHOT_ID" --zone "$VM_ZONE" --sku Standard_LRS

Attach the disk and start the VM

Attaching works while the VM is deallocated. This attaches the disk at LUN 0 and starts the VM:

Bash
az vm disk attach --resource-group "$VM_GROUP" --vm-name "$VM_NAME" \
  --name "$INSPECT_DISK" --lun 0
az vm start --resource-group "$VM_GROUP" --name "$VM_NAME"

Mount the disk from your terminal

Run Command runs a script inside the guest through the VM agent, and returns its output to your terminal.

Warning

Test these scripts on a VM and disk you can afford to lose before you run them on yours. inspect.ps1 changes the state of every disk except disk 0, and the cleanup commands delete disks and resource groups. Run Get-Disk on the VM first to confirm that disk 0 is its OS disk.

Save this as inspect.ps1. It brings every disk other than the OS disk online, clears read-only, and gives a drive letter to any partition over 10 GB that lacks one:

PowerShell
Get-Disk | Where-Object Number -ne 0 | ForEach-Object {
    if ($_.IsOffline) { Set-Disk -Number $_.Number -IsOffline $false }
    if ($_.IsReadOnly) { Set-Disk -Number $_.Number -IsReadOnly $false }
    Get-Partition -DiskNumber $_.Number |
        Where-Object { -not $_.DriveLetter -and $_.Size -gt 10GB } |
        Add-PartitionAccessPath -AssignDriveLetter
}
Get-Volume |
    Format-Table DriveLetter, FileSystemLabel, FileSystem, Size, SizeRemaining

In the terminal, run it on the VM:

Bash
az vm run-command invoke --resource-group "$VM_GROUP" --name "$VM_NAME" \
  --command-id RunPowerShellScript --scripts @inspect.ps1 \
  --query "value[].message" --output tsv

The old disk appears as a second Windows volume beside the VM’s own C:. On mine it was E::

Text
DriveLetter FileSystemLabel FileSystem         Size SizeRemaining
----------- --------------- ----------         ---- -------------
            Recovery        NTFS          471855104     454938624
C           Windows         NTFS       135771664384   66295812096
D           System Reserved NTFS          524283904     488136704
E           Windows         NTFS       135838822400  105699086336
                            FAT32          99614720      65237504

List what’s on it

Run Command returns only the end of what the script prints, and drops the rest without an error; the size is in the Run Command restrictions (opens in a new tab). So print one line per folder, cap any search, and skip the Windows folder, which took more than ten minutes to measure on mine.

This script prints one line per top-level folder, skips Windows, and caps the search for game folders at 15 results. It reads the old disk as E:. If yours got another letter in the volume output, replace each E: with it. Save it as list.ps1:

PowerShell
function Get-SizeGB($path) {
    $bytes = (Get-ChildItem -LiteralPath $path -Recurse -Force -File `
        -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
    '{0:N1}' -f ($bytes / 1GB)
}
$skip = 'Windows', 'System Volume Information', '$Recycle.Bin',
    'Documents and Settings', 'Recovery'
Get-ChildItem E:\ -Force -Directory -ErrorAction SilentlyContinue |
    Where-Object Name -notin $skip |
    ForEach-Object {
        '{0} {1} {2}' -f (Get-SizeGB $_.FullName),
            $_.LastWriteTime.ToString('yyyy-MM-dd'), $_.Name
    }
$gameNames = '(?i)^(rust|steamcmd|steamapps|server|minecraft|world.*|saves?)$'
Get-ChildItem E:\ -Recurse -Force -Directory -Depth 4 `
    -ErrorAction SilentlyContinue |
    Where-Object {
        $_.FullName -notmatch '^E:\\(Windows|ProgramData\\Microsoft)' -and
        $_.Name -match $gameNames
    } |
    Select-Object -First 15 |
    ForEach-Object {
        '{0} {1} {2}' -f (Get-SizeGB $_.FullName),
            $_.LastWriteTime.ToString('yyyy-MM-dd'), $_.FullName
    }

Run it the same way, with --scripts @list.ps1. The top-level folders, with size in GB and the last-modified date:

Text
0.1 2020-10-10 jre8
0.2 2024-01-08 Minecraft
0.0 2022-12-11 Packages
0.0 2020-05-13 PerfLogs
0.4 2024-01-08 Program Files
0.0 2016-07-16 Program Files (x86)
0.9 2018-12-04 ProgramData
0.0 2018-12-04 runRust
0.1 2018-12-04 RustAdminRelease
6.1 2024-01-08 rustserver
0.2 2024-01-08 steamcmd
2.1 2023-12-29 Users
1.3 2023-12-29 WindowsAzure

rustserver was the Rust server I remembered. Its save folder held two server identities, the older from April 2020 and the newer last written in December 2023. A Rust server wipes its map on a schedule, so those saves had no value to me.

Minecraft was the folder I had forgotten about. It held a survival world, E:\Minecraft\world, 100 MB across 25 region files. The newest region file was dated April 2021, the last time the server saved the world. The folder also held two Java crash logs from January 2024.

The VM I already run has a Minecraft world too, but a different one, so this world was the only copy and I kept it.

Zip what you keep to C:\Archive\<file-name>.zip on the OS disk of the VM you already run, with Compress-Archive through Run Command. Before you delete anything, check that the zip holds as many files as the folders you zipped. Mine held 80: 76 world files plus four server config files.

Copy what you keep off the VM

A zip on the OS disk of the VM you already run is one disk failure from gone, and once the snapshot is deleted it’s the only copy. Put it in object storage before you delete the snapshot.

Any object store works. I used S3, where my 7 Days to Die backups already go. Check the bucket’s lifecycle rules before you choose it. The bucket my backups use was the obvious place, but its rule expires every object after 30 days, not only the backups:

Bash
aws s3api get-bucket-lifecycle-configuration --bucket "<bucket-name>" \
  --query "Rules[].{id:ID,filter:Filter,expiration:Expiration}"

A rule with an empty Filter, or a Prefix of "", applies to the whole bucket. I used a different bucket, one whose only rule removes expired delete markers.

The hash check at the end of this section works only when the bucket encrypts with SSE-S3, which is S3’s default.

To upload from the VM without putting AWS credentials on it, generate a presigned PUT URL on your own machine. The AWS CLI’s aws s3 presign signs only downloads, so this uses boto3 through uv. The URL expires after 30 minutes:

Bash
uv run --no-project --with boto3 python -c "
import boto3
from botocore.config import Config
s3 = boto3.client('s3', region_name='<aws-region>',
                  config=Config(signature_version='s3v4'))
print(s3.generate_presigned_url('put_object', ExpiresIn=1800,
      Params={'Bucket': '<bucket-name>', 'Key': 'archive/<file-name>.zip'}))"

Through Run Command on the VM, upload the file to that URL and print both hashes. Set $url to the URL from the previous step:

PowerShell
$file = 'C:\Archive\<file-name>.zip'
$response = Invoke-WebRequest -Uri $url -Method Put -InFile $file `
    -UseBasicParsing
"etag $($response.Headers['ETag'])"
"md5  $((Get-FileHash $file -Algorithm MD5).Hash.ToLower())"

For a single-part upload with SSE-S3 encryption, the ETag S3 returns is the object’s MD5 (opens in a new tab), so matching values mean S3 has the same bytes. Under SSE-KMS or SSE-C the ETag isn’t an MD5, and the two values differ even when the bytes are the same. Mine matched, at 73,508,292 bytes.

Clean up

This detaches the temporary disk and deletes it. az disk delete asks for confirmation. Run the last command only if the VM was deallocated before you started; it deallocates the VM again so it stops billing for compute:

Bash
az vm disk detach --resource-group "$VM_GROUP" --vm-name "$VM_NAME" \
  --name "$INSPECT_DISK"
az disk delete --resource-group "$VM_GROUP" --name "$INSPECT_DISK"
az vm deallocate --resource-group "$VM_GROUP" --name "$VM_NAME"

Confirm the VM no longer lists a data disk:

Bash
az vm show --resource-group "$VM_GROUP" --name "$VM_NAME" \
  --query "storageProfile.dataDisks" --output json

An empty list, [], means the disk is detached.

Delete the snapshot last, once the copy in object storage is confirmed. List the archive group first, so the delete removes only what you expect. az group delete asks for confirmation:

Bash
az resource list --resource-group "$ARCHIVE_GROUP" \
  --query "[].{name:name,type:type}" --output tsv
az group delete --name "$ARCHIVE_GROUP"
az group exists --name "$ARCHIVE_GROUP"

false means the group and the snapshot in it are gone. From the old disk, I kept one 73.5 MB zip.

New articles, by email.

A short note when something new is published.