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
graphrootis not a universal migration procedure. Existing database references can prevent a direct path change. - Rootless metadata matters.
podman unsharewithrsync -aHAX --numeric-idspreserved 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, anddfare 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.