Skip to main content

Migrate Docker Compose volumes to local bind mounts

·817 words·4 mins·
Photo by Anne Nygård on Unsplash
Named volumes are great for portability, but bind mounts are often easier to inspect, back up, and manage with your host tooling. In this guide, we replace a Docker Compose service volume with a local bind mount and migrate existing data safely.

Why switch from volume to bind mount?
#

Named volumes like app_data:/var/lib/app are great: Docker manages everything for you. But here’s the trade-off: your files are tucked away inside Docker’s internals, making them harder to access directly.

Bind mounts like ./data/app:/var/lib/app work differently. They store files directly in your project folder (or wherever you choose on the host), which makes life easier for:

  • Host-level backups. Just grab the folder and go.
  • Direct file inspection and edits. No need to jump into containers.
  • Migration to another host. Standard tools like rsync, tar, NAS snapshots, and friends just work.

Before you start
#

Let’s get your ducks in a row:

  • Shell access to the Docker host. You’ll need to run commands on the machine running Docker.
  • A short downtime window. Plan for a few minutes when the service can be offline.
  • Know your service and mount path. Jot down what you’re migrating and where.

Throughout this guide, we’ll migrate /var/lib/app from the named volume app_data to a local folder at ./data/app.

1. Original Compose service (named volume)
#

services:
  app:
    image: ghcr.io/example/my-app:latest
    container_name: app
    volumes:
      - app_data:/var/lib/app
    restart: unless-stopped

volumes:
  app_data:

2. Stop the stack cleanly
#

First things first: stop your containers before copying files. This prevents the app from writing to the volume while you’re copying, which could mess up your data.

docker compose down

3. Create the destination directory
#

mkdir -p ./data/app
Tip

Use an absolute path in Compose if you’re deploying from somewhere other than the project root. This saves you headaches later.

4. Copy data from volume to local directory
#

Here’s where the magic happens. We’ll spin up a temporary Alpine container to copy your data while keeping all the important stuff—permissions, timestamps, symlinks—intact.

docker run --rm \
  -v app_data:/from:ro \
  -v "$(pwd)/data/app:/to" \
  alpine sh -c 'cd /from && tar cf - . | tar xpf - -C /to'
Tip

Why tar? It’s everywhere (even in tiny Alpine images), and it copies entire directory trees reliably, keeping permissions, timestamps, symlinks, and dotfiles intact. Plus, it streams directly from source to destination without creating a temporary file in between. If you prefer, cp -a works fine for simpler cases, and rsync -a is fantastic if you’ve got it available.

Want to verify it worked? Run a quick sanity check:

docker run --rm -v app_data:/from:ro alpine sh -c 'ls -la /from | head'
ls -la ./data/app | head

5. Update Compose to use bind mount
#

Replace the service volume entry:

services:
  app:
    image: ghcr.io/example/my-app:latest
    container_name: app
    volumes:
      - ./data/app:/var/lib/app
    restart: unless-stopped

If app_data was only used by this service, remove it from the volumes: section.

6. Fix ownership if needed
#

Here’s a gotcha: some containers run as non-root users. If your app can’t read or write to the new bind mount, you’ll need to fix the permissions.

# Example for uid:gid 1000:1000
sudo chown -R 1000:1000 ./data/app

Not sure which UID/GID your image uses? Start the container and check the logs. You’ll spot permission errors right away.

7. Start and validate
#

Time for the moment of truth:

docker compose up -d
docker compose logs -f app

Here’s what to look for:

  • No initialization errors. The app should start cleanly.
  • Your data’s still there. Check that old data wasn’t lost.
  • New writes show up locally. Create a test file or watch for logs to verify it’s writing to ./data/app on the host.

Rollback plan
#

If things go sideways, don’t panic. Your original data is safe:

  1. docker compose down
  2. Revert Compose back to app_data:/var/lib/app
  3. docker compose up -d

The original named volume’s still there (we didn’t delete it), so you’re good to go.

Clean up old volume (only after verification)
#

Once you’re 100% sure the migration went smoothly and you’ve got a backup:

docker volume ls
docker volume rm app_data

But seriously, don’t rush this step. Only delete the old volume when you’re certain everything’s working.

Bonus: Migrating with minimal downtime
#

Running a database or indexer? Here’s a pro move to cut downtime even more:

  1. First copy while the service is still running.
  2. Stop the service.
  3. Second copy to catch any recent changes.
  4. Switch Compose and restart.

This two-pass strategy keeps your service down for just a few minutes instead of however long a full copy takes.

Final notes
#

  • Bind mounts shine when you’re managing backups yourself on the host.
  • Named volumes win when you want Docker handling the storage abstraction for you.
  • For production databases, don’t rely solely on filesystem copies. Use the database’s native backup/restore tools too.

For a broader command reference, see Docker and Docker Compose Cheatsheet.