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:

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

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)

  1. 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.
  2. Run the final sync, this time with --delete so 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.
  3. 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

Phase 4 — Verification

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

← Back to Technical Articles