Select your language

When you build container images frequently, container storage can fill the system disk without making the cause obvious. In my Protos development environment, built around GraalVM and Truffle, I use rootless Podman for several VS Code devcontainers and for building images with different GraalVM versions. Eventually / reached 97% usage, while a second disk mounted at /datos still had 386 GB free.

The goal was to move Podman's data to that second disk without losing images, containers, or volumes. The copy itself was straightforward. The interesting part came when Podman rejected the new graphroot because its database still remembered the old path. Here is the actual procedure, including the error and measured results.

1. Diagnosis: how much space does Podman really use?

These were the starting figures:

  System disk / Data disk /datos
Device /dev/nvme0n1p7 /dev/sda1
File system Btrfs ext4
Capacity 192 GB 458 GB
Used 180 GB 49 GB
Free 7.5 GB 386 GB
Usage 97% 12%

I inspected Podman and both file systems:

podman system df -v
podman info --format '{{.Store.GraphRoot}}'
df -h / /datos
df -T /datos

Podman used the default rootless path:

/home/guille/.local/share/containers/storage

There were 142 images, 6 containers (3 running), and 2 volumes. The vscode volume alone was about 6.3 GB. Once storage activity had stopped, its measured size was:

podman unshare du -sh ~/.local/share/containers/storage
# 39G  /home/guille/.local/share/containers/storage

Do not simply add image sizes: images share layers. The graphRootUsed value is not necessarily the exclusive size of Podman's files, and du and df need not agree on Btrfs, which may have shared blocks and snapshots.

2. Rootless storage needs special care

In rootless mode, files in container layers may be owned by subordinate user and group IDs. I checked the mappings:

podman unshare cat /proc/self/uid_map
podman unshare cat /proc/self/gid_map

Both returned:

0       1000       1
1       100000     65536

These ranges were also declared in /etc/subuid and /etc/subgid. A normal copy that alters ownership, ACLs, or extended attributes can break container permissions, so I used podman unshare and rsync to preserve the required metadata.

3. Stop storage activity before copying

I saved my work, closed the sessions attached to the devcontainers, and stopped the running builds. From a host terminal, not from a container:

podman stop --all
systemctl --user stop podman.socket podman.service
podman ps -a
podman unshare podman mount

All six containers were stopped and no mounted layers were reported. Any other tool, such as Buildah, that writes to the same storage must also be idle during the copy.

4. Copy while preserving permissions and metadata

I chose /datos/podman on ext4, with ample free space. With Podman storage idle:

podman unshare mkdir -p /datos/podman

podman unshare rsync -aHAX \
  --numeric-ids \
  --info=progress2 \
  /home/guille/.local/share/containers/storage/ \
  /datos/podman/

echo "RSYNC_EXIT=$?"

-a preserves the directory structure, ownership, and ordinary file attributes; -H preserves hard links; -A preserves ACLs; -X preserves extended attributes; and --numeric-ids avoids translating identities by name. The operation completed with RSYNC_EXIT=0, transferring around 40 GB and approximately 375,900 files.

I checked the sizes of both trees and then ran a dry-run verification:

podman unshare du -sh /home/guille/.local/share/containers/storage
podman unshare du -sh /datos/podman

podman unshare rsync -aHAXn \
  --numeric-ids \
  --itemize-changes \
  /home/guille/.local/share/containers/storage/ \
  /datos/podman/

Both directories measured 39 GB. The only reported difference was:

>f..t...... storage.lock

Only the modification time of Podman's lock file differed. This dry run checks sizes and metadata; it is not a byte-for-byte cryptographic comparison.

5. The error: “database static dir does not match our static dir”

My first attempt used the standard rootless configuration file, ~/.config/containers/storage.conf:

[storage]
driver = "overlay"
graphroot = "/datos/podman"

But podman info failed with:

Error: database static dir
"/home/guille/.local/share/containers/storage/libpod"
does not match our static dir
"/datos/podman/libpod":
database configuration mismatch

The copy had succeeded. The problem was that the database for an existing Podman installation still referenced the previous absolute path. Changing graphroot alone did not migrate those references.

I did not want to reset Podman or recreate my devcontainers. Instead I kept the original logical path and moved only the physical storage location.

6. The solution: a Linux bind mount

A bind mount exposes an existing directory at a second path. Podman could continue to use:

/home/guille/.local/share/containers/storage

while the bytes were actually stored at:

/datos/podman

First I disabled the new configuration:

mv ~/.config/containers/storage.conf \
  ~/.config/containers/storage.conf.bak

With the containers still stopped, I preserved the original directory and created a mount point:

mv ~/.local/share/containers/storage \
  ~/.local/share/containers/storage.before-datos

mkdir -m 700 ~/.local/share/containers/storage

sudo mount --bind \
  /datos/podman \
  /home/guille/.local/share/containers/storage

Then I verified the setup:

findmnt -T ~/.local/share/containers/storage
podman info --format '{{.Store.GraphRoot}}'
podman ps -a
podman volume ls

findmnt identified /dev/sda1[/podman] (ext4) as the actual source. GraphRoot was once again the old path, and Podman recognized all six containers and both volumes without a database error.

The key insight is that the path an application uses and the device storing its bytes do not need to be identical concepts.

7. Make the mount persistent with systemd

A manual bind mount disappears after a reboot. I backed up /etc/fstab:

sudo cp -a /etc/fstab /etc/fstab.before-podman

And added:

/datos/podman /home/guille/.local/share/containers/storage none bind,x-systemd.requires-mounts-for=/datos 0 0

The x-systemd.requires-mounts-for=/datos option declares the dependency on the data disk's mount. I reloaded systemd and checked the result:

sudo systemctl daemon-reload
sudo findmnt --verify --verbose
systemctl status \
  home-guille-.local-share-containers-storage.mount

findmnt reported no errors or warnings, and the mount unit was active. That confirmed the configuration in the current session; a full reboot test remained outstanding.

Important: after rebooting, verify that the bind mount points to /dev/sda1[/podman] before starting Podman. If /datos is missing, Podman must not be allowed to create a new storage tree on the underlying system disk.

8. Functional check and removal of the backup

I restarted the three development containers that had been running before the migration:

podman start \
  096448da129c \
  685fa0652baa \
  c2c2f6f903be

podman ps
podman volume inspect vscode \
  --format '{{.Mountpoint}}'

All three showed Up. The vscode volume remained accessible through Podman's original logical path, which now resolved to /datos.

Before reclaiming the disk space, I checked that the old backup was not a mount point and the active storage directory was:

sudo du -sh \
  /home/guille/.local/share/containers/storage.before-datos

findmnt --mountpoint \
  /home/guille/.local/share/containers/storage.before-datos

findmnt --mountpoint \
  /home/guille/.local/share/containers/storage

After validating the working containers, I deleted only the backup:

sudo rm -rf --one-file-system \
  /home/guille/.local/share/containers/storage.before-datos

This is irreversible and must never be run until the copy, mount, and containers have been validated. --one-file-system does not protect you if you target the wrong directory.

9. Prune leftover intermediate images

The migration changed the disk holding Podman's data, but did not itself remove old intermediate images. To remove only dangling images, I ran:

podman image prune

Podman requested confirmation and removed old, unused intermediate images. I avoided aggressive --volumes cleanup because the devcontainer volumes needed to remain intact.

Usage of the entire /datos file system went from around 87 GB before this stage to 76 GB afterward. This is a disk-level observation, not a precise sum of reclaimed image sizes.

10. Measured results

Metric Before After
Free space on / 7.5 GB 31 GB
Usage on / 97% 84%
Free space on /datos 386 GB 359 GB
Usage on /datos 12% 18%
Podman storage directory 39 GB 35 GB
Containers preserved 6 6
Volumes preserved 2 2
Development containers started 3 3

Free space on the system disk increased by 23.5 GB. Although du initially reported 39 GB, the measured free-space increase was smaller. Btrfs can retain shared blocks and snapshots, and du and df measure different things. I did not perform a snapshot or extent audit, so I cannot attribute the full difference to a specific cause.

The remaining 35 GB of Podman storage now resides physically on /datos, not on /.

11. Lessons learned

  • Changing graphroot is not a universal migration procedure. Existing database references can prevent a direct path change.
  • Rootless metadata matters. podman unshare with rsync -aHAX --numeric-ids preserved it in this case.
  • Bind mounts separate logical paths from physical storage. Existing Podman references continued to work.
  • Persistent mounting must be tested. The systemd configuration was validated, but an actual reboot remains a useful final test.
  • Never delete the old data first. Copying, verifying, mounting, checking volumes, starting containers, and removing the backup were distinct operations.
  • Measure actual disk usage. Image sizes, du, and df are not interchangeable.

Conclusion

The migration finished without recreating containers or volumes. A complete copy from Btrfs to ext4 moved the data; a bind mount solved the old absolute paths stored in Podman's database; and systemd was configured to establish the dependency on /datos.

The outcome was 23.5 GB more free space on the system disk, three working development containers, and Podman's storage on the data disk. Validation after a full reboot remains the final operational check.

References