Migrating Docker Compose Data Volumes Between Disks Without Downtime or Data Loss
A recurring request we get is: "our Docker Compose stack's data disk is almost full — can we move it
to a bigger volume without breaking production?" It sounds like a simple mv, but once
you factor in live databases, mixed file ownership between containers, and a source disk with only a
few gigabytes free, it stops being trivial. Below is the playbook we used for a recent multi-container
AI/document-processing stack: a Compose project with a web frontend, a MySQL database, a Postgres
instance with the pgvector extension, an MCP service, a RAG service, an AI worker, and a
document-conversion service — seven containers sharing one large data directory.
The starting point
The project followed a common pattern: a single WORK_DIR variable in .env
controlled every bind-mounted volume in docker-compose.yml —uploaded documents, processed
file fragments, OCR models, MySQL's data directory, and the vector database's data directory all lived
under ${WORK_DIR}/.... The application code itself never referenced the host path directly;
it only ever saw the in-container mount points, so the entire migration could be done by changing one
variable and moving the bytes underneath it.
That convenience came with a few complications worth checking for before touching anything:
- Cross-filesystem move. The source and destination directories lived on two different
LVM logical volumes on two different disks. That rules out a fast in-place
mvor rename — every byte has to be physically copied. - ~75 GB across mixed ownership. Uploaded files and processed fragments belonged
to the application's own service account; the MySQL data directory was owned by the UID MySQL runs
as inside its container; the Postgres/pgvector data directory was
root-owned with0700permissions. A naivecpas a regular user would silently fail to read some of this and would never be able to restore the original ownership. - Source disk at 96% full. Only a few gigabytes of headroom left — a strong signal that this migration was overdue, and a reminder that anything going wrong mid-copy (like accidentally writing back to the source) had almost no safety margin.
Why "just stop and copy" is the wrong first move
With ~75 GB to move, a straight copy during a maintenance window would mean an uncomfortably long outage while the business waits on disk I/O. The better approach is to do the slow part while the system is still live, and keep the actual downtime limited to the small delta that changed since the last copy.
The migration plan
Phase 0 — Preparation
- Check
cronand any systemd timers for jobs that touch the data directory or the backup scripts, so the migration window doesn't collide with a scheduled backup or sync job. - Pre-create the destination directory with the correct top-level owner and group before copying anything into it.
- Take a fresh backup and confirm the existing offsite backup/rsync job actually succeeded recently — this is the safety net if the migration goes wrong.
Phase 1 — Warm copy (no downtime)
As root, run an initial pass with rsync, preserving ownership, permissions, ACLs, and
hardlinks, and without --delete yet:
rsync -aHAX --info=progress2 /mnt/old-volume/app/ /mnt/new-volume/app/
The stack keeps running normally while this copies in the background. Re-run the same command once or twice more before the scheduled maintenance window — each pass only needs to copy what changed since the previous one, so it gets dramatically faster every time.
Phase 2 — Maintenance window (short downtime)
- Announce the downtime and stop the stack cleanly:
docker compose down. This matters more than it might look — MySQL and Postgres need to flush and close their files properly before a final copy, or you risk copying a database in an inconsistent state. - Run the final sync, this time with
--deleteso removed files are reflected too:rsync -aHAX --delete /mnt/old-volume/app/ /mnt/new-volume/app/
Because most of the data was already copied in Phase 1, this pass should be fast. - Spot-check the result: compare directory sizes and file counts between source and destination. For the database data directories, matching file sets are a good-enough sanity check at this stage — a full logical consistency check happens later, once the database is actually running again.
Phase 3 — Cut over
- Point
WORK_DIR(in both.envand the deployment template) at the new location. - Bring the stack back up:
docker compose up -d, and watch the logs closely — especially for any services with healthchecks (database and vector-store containers in particular) until they report healthy.
Phase 4 — Verification
- Run whatever smoke tests the project has, plus a manual pass: log in, upload a document, and exercise a search/RAG query end to end — that single action touches the upload volume, the relational database, and the vector database all at once.
- Keep an eye on the stack under real traffic for a day or two before touching the old data.
Phase 5 — Reclaim the old disk
Only after the new location has run stably for a reasonable period, remove the old data to free up space on the original, nearly-full disk.
Rollback
Because the old data isn't deleted until Phase 5, rolling back after a bad cutover is just:
docker compose down, point WORK_DIR back at the old location, and
docker compose up -d again.
Takeaways
- A single environment variable driving all bind mounts makes this kind of migration painless — it's worth structuring new Compose projects that way from the start.
- Always copy as root with
rsync -aHAX(orcp -a) when volumes have mixed per-container ownership — anything less silently loses permissions you can't easily reconstruct later. - Stop stateful services (databases in particular) before the final sync, not the warm-up passes. That's what keeps the maintenance window short without risking a corrupted database copy.
- Don't delete the old copy until the new one has proven itself under real traffic — it's the cheapest rollback plan you'll ever have.