Resizing a Linux VM Disk: The Five Layers Nobody Warns You About

Resizing a Linux VM Disk: The Five Layers Nobody Warns You About

You dragged a slider to 150 GB and thought you were done. Then Linux shrugged and kept showing 60 GB. Here is the story of every layer you have to peel back — and why each one exists.

Mike Chumba Mike Chumba
10 min read
1984 words

At about 11 p.m., a staging server crossed 82 percent disk usage on / and kept climbing.

It was an Ubuntu 24.04 VM running inside UTM on a forever-online Mac. The culprit was familiar: a CI pipeline quietly hoarding Docker layers until the disk had almost no room left.

The fix looked like the kind of thing that should take two minutes. Open UTM. Find the VirtIO drive. Drag the slider from 100 GB to 150 GB. Click Resize. Restart the VM.

Then I SSH-ed in and ran the command every sysadmin reaches for first:

df -h /

The output was merciless:

Filesystem                         Size  Used Avail Use% Mounted on
/dev/mapper/ubuntu--vg-ubuntu--lv   60G   49G   10G  82% /

Sixty gigabytes.

Not 150. Not even 100. Linux was still living inside the old world.

That is the part most disk-resize guides hide in the fine print. You do not resize “the disk.” You resize a stack. Each layer has its own idea of size, and none of them politely updates the next one for you.

Storage Is a Stack, Not a Slab

The VM disk is only the bottom layer.

On a modern Linux system, especially one with encryption and LVM, storage is a stack of block devices and metadata. In my case, the stack had five distinct layers:

┌─────────────────────────────────────┐
│  Layer 5: ext4 Filesystem           │  ← Your files live here
├─────────────────────────────────────┤
│  Layer 4: LVM Logical Volume        │  ← A flexible "virtual partition"
├─────────────────────────────────────┤
│  Layer 3: LVM Physical Volume       │  ← Raw material registered with LVM
├─────────────────────────────────────┤
│  Layer 2: LUKS Encryption           │  ← Full-disk encryption container
├─────────────────────────────────────┤
│  Layer 1: Partition (vda3)          │  ← A defined region of the disk
├─────────────────────────────────────┤
│  Layer 0: Virtual Disk (vda)        │  ← The raw .qcow2 file from UTM
└─────────────────────────────────────┘

When I resized the drive in UTM, I changed Layer 0. Every other layer still carried metadata from the original 60 GB installation. An earlier extension to 100 GB had produced no useful space either. I had missed that until the server was already close to full.

Each layer only trusts the layer directly below it. You have to grow them from the bottom up.

Layer 1: Move the Partition Boundary

First, prove that the kernel sees the larger virtual disk:

lsblk
NAME                        MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
vda                         253:0    0   150G  0 disk
├─vda1                      253:1    0     1G  0 part  /boot/efi
├─vda2                      253:2    0     2G  0 part  /boot
└─vda3                      253:3    0  60.9G  0 part
  └─dm_crypt-0              252:0    0  60.9G  0 crypt
    └─ubuntu--vg-ubuntu--lv 252:1    0  60.9G  0 lvm   /

vda shows 150 GB. Good.

vda3, the partition that contains everything important, is still stuck at 60.9 GB. That means the extra space exists, but no partition owns it.

A partition is a region on a disk with a start byte and an end byte recorded in the partition table. GPT, in this case. When Ubuntu was installed on a 60 GB disk, the installer created vda3 starting at sector 6,397,952 and ending at sector 134,215,679. That end boundary was still anchored to the old disk size.

The 87 GB gap between the end of vda3 and the end of vda was unclaimed space.

The fix:

sudo growpart /dev/vda 3
CHANGED: partition=3 start=6397952 old: size=127817728 end=134215679
                                    new: size=308174815 end=314572766

growpart is a narrow tool with one job. It reads the partition table, identifies that partition 3 is the last partition on the disk, and extends its end boundary to the last usable sector. No data is moved. No filesystem is touched. It only rewrites a few bytes of metadata in the GPT header.

This is just moving the fence. The land inside is still undeveloped.

Layer 2: Grow the LUKS Container

This is the layer I forgot at first: full-disk encryption with LUKS.

LUKS works by wrapping the entire partition in an encryption container. It stores a header at the beginning of vda3 that includes the master key (itself encrypted with your passphrase), cipher configuration, and — critically — the size of the encrypted payload area. Even though the partition underneath had grown, the LUKS header still declared the encrypted region as 60.9 GB.

Everything inside LUKS — the LVM layer, the logical volume, the filesystem, your files — is encrypted. The kernel presents the decrypted data as a virtual device at /dev/mapper/dm_crypt-0. But that virtual device was still sized according to the old LUKS metadata.

The fix:

sudo cryptsetup resize dm_crypt-0

This command required the LUKS passphrase, which is separate from the user password. cryptsetup updated the LUKS metadata to declare the full partition as the encrypted area, and the kernel immediately resized the /dev/mapper/dm_crypt-0 device.

On my first attempt, I skipped straight to pvresize and got a confusing error about the physical volume already being at maximum size. The error was technically true from LVM’s point of view. It could only see the decrypted device LUKS exposed to it, and that device was still 60.9 GB.

The LUKS layer was blocking everything above it from seeing the new space.

If your system uses LUKS, check it early. It sits quietly between the partition and LVM, and its size mismatch produces errors that point you in the wrong direction.

Layer 3: Update the LVM Physical Volume

With the partition grown and the LUKS container expanded, LVM finally had a larger device underneath it. But LVM still had its own old metadata.

LVM operates on three abstractions:

  • Physical Volume (PV): A raw block device registered with LVM. In my case, the decrypted LUKS device.
  • Volume Group (VG): A pool that aggregates one or more PVs into a single logical storage pool. Mine is called ubuntu-vg.
  • Logical Volume (LV): A virtual partition carved from the VG. Mine is ubuntu-lv, mounted at /.

The PV still believed it was 60.9 GB. It was sitting on a decrypted device that was now 146.9 GB, but LVM does not automatically turn that into usable extents.

The fix:

sudo pvresize /dev/mapper/dm_crypt-0
Physical volume "/dev/mapper/dm_crypt-0" changed
1 physical volume(s) resized or updated / 0 physical volume(s) not resized

pvresize scanned the underlying device, noticed it was now ~86 GB larger, and added those bytes to the PV as free physical extents (4 MB chunks, by default). These free extents became available in the Volume Group.

This step updates the ledger. The storage exists. LVM now admits it exists.

Layer 4: Assign Space to the Logical Volume

The Volume Group ubuntu-vg now had ~86 GiB of free space. The Logical Volume ubuntu-lv, where the filesystem actually lives, still used only 60.93 GiB.

The fix:

sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
Size of logical volume ubuntu-vg/ubuntu-lv changed from <60.93 GiB
  (15598 extents) to <146.93 GiB (37614 extents).
Logical volume ubuntu-vg/ubuntu-lv successfully resized.

The -l +100%FREE flag is the key: it says “take every last free extent in the Volume Group and give it to this Logical Volume.” You could also specify a fixed size (e.g., -L +80G) if you wanted to leave some free space for snapshots or other volumes.

This is the moment the free space becomes part of /dev/ubuntu-vg/ubuntu-lv. It still is not part of the filesystem.

Layer 5: Grow ext4

This is the layer users actually experience.

The ext4 filesystem organizes files into directories, tracks permissions, handles journaling for crash recovery, and maintains the bitmaps that know which disk blocks are free or full.

ext4 stores this information in structures called block groups, each containing an inode table and a block bitmap. When the filesystem was created during installation, it built exactly enough block groups to cover 60 GB. The Logical Volume underneath could now be 150 GB — ext4 would not use a single extra byte until told to expand.

The fix:

sudo resize2fs /dev/ubuntu-vg/ubuntu-lv
resize2fs 1.47.0 (5-Feb-2023)
Filesystem at /dev/ubuntu-vg/ubuntu-lv is mounted on /; on-line resizing required
old_desc_blocks = 8, new_desc_blocks = 19
The filesystem on /dev/ubuntu-vg/ubuntu-lv is now 38516736 (4k) blocks long.

Two details matter. First, this ran online while / was mounted and serving I/O. Second, it finished in under a second. No reformatting. No data migration. Just filesystem metadata expansion.

The Proof

After all five layers were resized, the moment of truth:

df -h /
Filesystem                         Size  Used Avail Use% Mounted on
/dev/mapper/ubuntu--vg-ubuntu--lv  145G   50G   90G  36% /

From 82 percent usage down to 36 percent. Ninety gigabytes of free space. No reboot. No downtime. The CI pipeline that had been quietly filling the disk resumed without ever knowing there had been a problem.

You Will Need This

Disk expansion is routine infrastructure work.

Development VMs Outgrow Their Allocation

Every developer who uses local VMs — UTM, Parallels, VirtualBox, QEMU — eventually runs out of disk. Node modules pile up. Docker images pile up. Logs pile up. Recreating the VM is punishment for not knowing the stack.

Cloud Instances Need Room to Breathe

AWS EBS volumes, Google Persistent Disks, and Azure Managed Disks can all be resized through their respective consoles or APIs. But the cloud provider only changes the block device — the same five layers (minus LUKS, usually) still need to be expanded inside the instance. AWS even documents this in a dedicated guide, but it is one of the most frequently asked support questions on every cloud platform.

Database Servers Hit Storage Limits

PostgreSQL WAL files, MySQL binary logs, MongoDB data directories: databases eat disk aggressively. When a production database approaches capacity at 2 a.m., the person who can expand a volume without taking the database offline is suddenly very valuable. The resize2fs online capability exists for this kind of pressure.

CI/CD Build Servers Fill Themselves

Build servers accumulate artifacts, Docker layers, compiler caches, and coverage data. A Jenkins or GitLab Runner VM that runs 50 builds a day can fill a 100 GB disk in weeks. Expand the storage and keep the pipeline moving.

Home Labs Grow Quietly

Nextcloud, Jellyfin, Home Assistant, backups, photos, surveillance footage: self-hosted services always want more disk eventually. The same technique works whether the VM lives on a Mac Mini or a rack-mounted Proxmox server.

Encrypted Laptop Partitions Add One More Trap

Full-disk encryption with LUKS is standard on most enterprise Linux laptops. When IT replaces a 256 GB SSD with a 1 TB drive during a hardware refresh, the same five-layer expansion is required — with the added pressure that this is someone’s daily workstation and they need it back immediately.

The Commands

The complete procedure:

# 1. Expand the partition to fill available disk space
sudo growpart /dev/vda 3

# 2. Expand the LUKS encryption container (skip if unencrypted)
sudo cryptsetup resize dm_crypt-0

# 3. Tell LVM the physical volume is larger
sudo pvresize /dev/mapper/dm_crypt-0

# 4. Extend the logical volume to use all free space
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv

# 5. Grow the filesystem to fill the logical volume
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv

If your system does not use LUKS, skip step 2. If it does not use LVM, skip steps 3 and 4 and run resize2fs directly on the partition. If the filesystem is XFS instead of ext4, replace resize2fs with xfs_growfs /.

The Real Lesson

The surprise comes from the wrong mental model.

You think you “have a 150 GB disk.” Linux sees a virtual disk, a partition, an encryption container, a physical volume, a logical volume, and a filesystem. Each layer is excellent at its own job. Each layer keeps a clean boundary. None of them assumes that a size change below it should automatically mutate the layer above it.

That is the price of modularity.

The same modularity lets you encrypt one volume and not another, span a Volume Group across physical disks, and resize ext4 online without unmounting /. The cost is that when the bottom grows, you must propagate the growth upward yourself.

At 11:47 p.m., resize2fs finished in 0.8 seconds. df -h finally showed 145 GB. The pipeline recovered. The machine had not needed drama. It had needed every layer told the truth.