Docker gives you several ways to keep data around when containers are recreated. Two of the most common are named Docker volumes and bind mounts. A named volume is managed by Docker, which in your docker-compose.yml could look like this:
volumes:
  - postgres-data:/var/lib/postgresql/data
A bind mount maps an ordinary directory on the host into the container:
volumes:
  - ./postgres-data:/var/lib/postgresql/data
Both approaches have their advantages. For self-hosted applications, however, there are situations where you may want to move from Docker-managed volumes to directories you control directly. This guide covers that migration.

Docker Volumes vs Bind Mounts

Before migrating anything, it is worth understanding what actually changes.

Docker named volumes

With a named volume, Docker manages the storage location. Small docker-compose.yml example below:
services:
  postgres:
    image: postgres:16-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:
The application sees /var/lib/postgresql/data, but the actual files are stored in Docker's volume storage.

Bind mounts

With a bind mount, you explicitly choose the host directory:
services:
  postgres:
    image: postgres:16-alpine
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
If your Compose project is located at:
/var/lib/docker/dockerProject
then the PostgreSQL data will be stored at:
/var/lib/docker/dockerProject/postgres-data
That difference is the main reason someone might want to migrate.

Docker Volumes - Pros and Cons

Pros Cons
Managed by Docker Data is hidden away in Docker's storage
Simple to configure Less convenient to inspect manually
Good container isolation Backups need to account for Docker volumes
Portable Compose configuration Moving data to another filesystem requires additional steps
No need to manage host directories Less obvious where application data physically lives

Bind Mounts - Pros and Cons

Pros Cons
Data lives exactly where you specify Host filesystem permissions matter
Easy to inspect with normal Linux tools Less portable between hosts
Easy to include in filesystem backups Host users can directly modify the files
Easy to move to another disk You are responsible for managing the directories
Project and persistent data can live together Incorrect manual changes can damage application data

Why Move From a Docker Volume to a Bind Mount?

There is no technical requirement to make this change. If Docker-managed volumes work well for you, there is nothing wrong with keeping them. A bind mount can nevertheless make sense if you want more direct control over your data. For example, I prefer self-hosted projects to look something like this:
dockerProject/
-rw-rw-r--  1 2645 Sep 19 22:32 docker-compose.yml
drwxr-xr-x  4 4096 Sep  8 23:20 minio-data
drwx------ 19 4096 Sep 20 08:10 postgres-data
drwxr-xr-x  2 4096 Sep 19 22:33 redis-data
.. Or even a dedicated "data" folder than contains all of the *-data directories above. When I need to back up, inspect or move the project, I know exactly where the persistent data is. This can also fit nicely into an existing rsync-based backup system because the data is simply part of the normal filesystem.

Important: Don't Just Change the Compose File

This is the most important part of the migration, really. Suppose you currently have:
volumes:
  - postgres-data:/var/lib/postgresql/data
and change it to:
volumes:
  - ./postgres-data:/var/lib/postgresql/data
Docker will not automatically copy the existing database into ./postgres-data. The old Docker volume still contains your database. The new directory is initially empty. You therefore need to copy the contents of the existing volume into the new directory before starting the application with the new configuration.

1. Find the Existing Volume

Start by finding the Docker volumes:
docker volume ls
If your project has a recognizable name, you can narrow the result:
docker volume ls | grep dockerProject
For example:
local     dockerProject_postgres-data
local     dockerProject_redis-data
local     dockerProject_minio-data
Use the actual names returned by Docker. Do not assume that your volume names will be identical to these examples.

2. Stop the Project

Stop the Compose project before copying database data:
docker compose down
This removes the containers but keeps the named volumes. Do not use docker compose down -v. The -v option removes the volumes, which would defeat the purpose of this migration.

3. Create the New Directories

Create the directories that will become your bind mounts - from the path where you want to store, in this example we use the directory of docker-compose.yml:
mkdir -p postgres-data redis-data minio-data
Because these paths begin with ./, they are relative to the directory containing your Compose file.

4. Copy the Docker Volume to the New Directory

A convenient way to copy a Docker volume is to temporarily mount it into a small Alpine Linux container. For PostgreSQL:
docker run --rm \
  -v dockerProject_postgres-data:/from:ro \
  -v "$PWD/postgres-data:/to" \
  alpine \
  sh -c 'cp -a /from/. /to/'
Redis:
docker run --rm \
  -v dockerProject_redis-data:/from:ro \
  -v "$PWD/redis-data:/to" \
  alpine \
  sh -c 'cp -a /from/. /to/'
MinIO:
docker run --rm \
  -v dockerProject_minio-data:/from:ro \
  -v "$PWD/minio-data:/to" \
  alpine \
  sh -c 'cp -a /from/. /to/'
The :ro flag makes the old volume read-only inside the temporary container. That means the migration command can read from the original volume but cannot modify it.

5. Check the Migrated Data

Check the size of the new directories:
du -sh postgres-data redis-data minio-data
You may need sudo for PostgreSQL:
sudo du -sh postgres-data redis-data minio-data
You can also inspect the contents:
sudo ls -la postgres-data
ls -la redis-data
find minio-data -maxdepth 2 -type f | head
A PostgreSQL data directory should contain things such as:
PG_VERSION
base/
global/
pg_wal/
postgresql.auto.conf
The exact contents depend on your PostgreSQL version and configuration.

6. Change Docker Compose

Once the data has been copied, change the Compose configuration. Before:
services:
  postgres:
    image: postgres:16-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:
After:
services:
  postgres:
    image: postgres:16-alpine
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
In this example we also have Redis and MinIO - do the same thing:
services:
  redis:
    image: redis:7-alpine
    volumes:
      - ./redis-data:/data

  minio:
    image: minio/minio:latest
    volumes:
      - ./minio-data:/data
The top-level volumes: section is no longer required for these mounts, so it can be removed.

7. Start the Project

Start the project again:
docker compose up -d
Check the containers:
docker compose ps
For PostgreSQL, check the logs if necessary:
docker compose logs postgres --tail=50
You want to see PostgreSQL successfully starting and accepting connections.

8. Verify the Application

Don't stop at checking that the containers are running. Actually use the application - ensure everything is as it should be. We haven't deleted any data, so it's still possible to revert in the docker-compose.yml file. This confirms that Docker is actually using the copied bind-mounted data rather than accidentally starting with an empty directory.

9. Keep the Old Volume for a While

Do not immediately delete the old volume. This gives you a fallback if you discover something went wrong with the migration. You can see the old volumes with:
docker volume ls
Once you have verified that everything works correctly from the new directories, the old volumes can eventually be removed:
docker volume rm dockerProject_postgres-data
docker volume rm dockerProject_redis-data
docker volume rm dockerProject_minio-data
Only remove them once you are certain you no longer need the old copies.

What About Backups?

One of the main reasons I prefer bind mounts is that they fit naturally into a normal filesystem backup strategy. Instead of having to specifically handle a Docker volume, the backup system can simply include the project directory:
/var/lib/docker/dockerProject/
That means the persistent directories are ordinary filesystem data. However, there is an important distinction for databases. A filesystem copy of a running PostgreSQL data directory should not automatically be treated as equivalent to a proper PostgreSQL backup. For important databases, consider using a database-aware backup such as pg_dump in addition to your filesystem backup.

Conclusion

Moving from Docker named volumes to bind mounts is relatively straightforward once you understand that changing the Compose file does not migrate the data for you. The basic process is:
  1. Find the existing Docker volume.
  2. Stop the application.
  3. Create the new host directory.
  4. Copy the volume contents into it.
  5. Change the Compose file to use the bind mount.
  6. Start the application.
  7. Verify everything works.
  8. Keep the original volume until you're satisfied with the migration.
The end result is a Docker project where the persistent data is stored directly alongside the project, making it easier to inspect, move and include in filesystem-level backups. There is nothing inherently wrong with Docker volumes, though. They remain an excellent option when you prefer Docker to manage persistent storage. Bind mounts simply give you a different trade-off: more direct control over the host filesystem in exchange for more responsibility.