06 April 2026
Docker Volumes Explained: Keep Your Data Safe (2026)
Quick one-liner: Learn how Docker volumes persist your data across container restarts — never lose data to container cleanup again.
🤔 Why This Matters
In the last tutorial, you learned how to manage your Docker environment — listing images, reading logs, setting environment variables, and cleaning up disk space.
At the end, you ran a MySQL container, created a users table, inserted a row, then stopped and removed the container. When you started a fresh one with the same environment variables and tried to query that table again:
ERROR 1146 (42S02): Table 'testdb.users' doesn't exist
Alice was gone. The container was deleted, and everything inside it went with it.
Why? Because containers are ephemeral. They're meant to be disposable. When a container dies, everything inside it goes with it.
That's fine for temporary scratch space, but it's not fine for: - 🗄️ Database files - 📁 Uploaded user content - ⚙️ Configuration you've customized - 📋 Logs you might need later
Today, we're fixing that. We're talking about Docker volumes. 🐳
Think of it like a food truck business 🚚. The truck can break down, get replaced, or upgraded, but as long as the fridge survives, the business keeps running. The container is the truck. The volume is the fridge 🧊.
By the end of this guide, you'll know how to keep your data safe, even when containers come and go.
✅ Prerequisites
- Docker installed (rootless mode recommended)
- Terminal access to your Docker host
- 5 minutes to persist your first volume
💾 Named Volumes (Let's Save Alice)
If you followed the previous post, you already saw Alice disappear. This time, we fix it with a named volume.
We attach a named volume to the MySQL data directory:
$ docker run -d --rm --name dtmysql \
-e MYSQL_ROOT_PASSWORD=docker \
-e MYSQL_DATABASE=testdb \
-v mysqldata:/var/lib/mysql \
mysql
The -v flag follows the format volume_name:container_path:
- mysqldata — the named volume Docker will create and manage for you
- /var/lib/mysql — where MySQL stores all its data inside the container
Anything MySQL writes to /var/lib/mysql is actually written to the mysqldata volume on your host, so it survives the container being removed.
Wait for mysqld is alive before proceeding:
$ docker exec dtmysql mysqladmin ping -u root -pdocker --silent
mysqld is alive
Insert the same data:
$ docker exec -it dtmysql mysql -u root -pdocker testdb
CREATE TABLE users (name VARCHAR(50));
INSERT INTO users VALUES ('Alice');
exit
Now stop the container — because we used --rm, Docker removes it automatically:
$ docker stop dtmysql
Run docker ps -a to confirm it is gone before we start fresh:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
No dtmysql in the list. Start a fresh container with the same volume:
$ docker run -d --rm --name dtmysql \
-e MYSQL_ROOT_PASSWORD=docker \
-e MYSQL_DATABASE=testdb \
-v mysqldata:/var/lib/mysql \
mysql
Query again:
$ docker exec -it dtmysql mysql -u root -pdocker testdb -e "SELECT * FROM users;"
+-------+
| name |
+-------+
| Alice |
+-------+
Alice survived 🎉 because the data lived in the volume, not in the container.
Let's peek under the hood with docker inspect to see exactly how Docker mapped the volume:
$ docker inspect dtmysql | less
Search for Mounts by typing /Mounts and pressing Enter. You should see something like this:
"Mounts": [
{
"Type": "volume",
"Name": "mysqldata",
"Source": "/home/<username>/.local/share/docker/volumes/mysqldata/_data",
"Destination": "/var/lib/mysql",
"Driver": "local",
"Mode": "z",
"RW": true,
"Propagation": ""
}
]
This tells you everything. The mysqldata volume on your host is mapped into /var/lib/mysql inside the container. Docker is managing a real directory on your machine and connecting it to the path MySQL uses for data storage.
Rootless note: In rootless mode, Docker stores volumes under your home directory (
~/.local/share/docker/volumes/) instead of the system-wide/var/lib/docker/volumes/.
You can also list all volumes on your host:
$ docker volume ls
DRIVER VOLUME NAME
local mysqldata
Clean up when you are done:
$ docker stop dtmysql
$ docker volume rm mysqldata
That is the core idea. Docker also supports another type of volume called a bind mount, and it works quite differently.
📂 Bind Mounts
A bind mount maps a directory on your host machine directly to a directory inside the container. Any changes on either side appear instantly on the other, so it is live.
Before we start, there is something important to understand if you are running rootless Docker.
🔑 Rootless Docker and UID Mapping
In rootless mode, container processes run in their own user namespace. Their UIDs get remapped to different UIDs on your host. The mapping works like this:
| Inside container | On your host |
|---|---|
| UID 0 (root) | Your user UID (e.g. 1000) |
| UID 1 | subuid_start |
| UID N | subuid_start + N - 1 |
The subuid_start is defined in /etc/subuid:
$ grep $USER /etc/subuid
davidtio:100000:65536
This means a container process running as UID 1000 lands on your host as UID 100999 (100000 + 1000 - 1). If your bind-mounted directory is owned by you (1000), that container process cannot write to it.
You can check what UID any image runs as. Let's start with nginx:
$ docker run --rm nginx id
uid=0(root) gid=0(root) groups=0(root)
Nginx runs as root (UID 0), which maps to your own UID on the host — so bind mounts just work. Now try the same with RedisInsight:
$ docker run --rm redis/redisinsight id
This one gets stuck. Unlike nginx, RedisInsight has an entrypoint script (docker-entry.sh) that starts the application immediately and never passes id through to run. Press Ctrl-C to stop it.
Every Docker image has an entrypoint — the command that runs when the container starts. Some images (like nginx) are designed to pass through any command you append. Others (like RedisInsight) always run their startup script regardless.
The --entrypoint flag lets you override the entrypoint completely. Instead of starting the app, the container runs id and exits:
$ docker run --rm --entrypoint id redis/redisinsight
uid=1000(node) gid=1000(node)
id is a standard Unix command available in any image built on a proper Linux distribution (Debian, Ubuntu, Alpine, etc.) — it works whether the container runs as root or any other user. Minimal scratch-based images like hello-world won't have it since they contain nothing but a single binary.
That maps to UID 100999 on the host with the default subuid base — not immediately obvious.
I prefer to update /etc/subuid and /etc/subgid to a base of 1000001, so container UID 1000 maps cleanly to host UID 1001000:
First, remove any existing entries (Docker may have added a default range on install), then add the clean 1000001 base:
$ sudo sed -i "/^$USER:/d" /etc/subuid
$ sudo sed -i "/^$USER:/d" /etc/subgid
$ sudo usermod --add-subuids 1000001-1065536 $USER
$ sudo usermod --add-subgids 1000001-1065536 $USER
$ systemctl --user restart docker
Verify:
$ grep $USER /etc/subuid
davidtio:1000001:65536
Now the formula is easy to reason about: container UID 1000 → host UID 1001000. Whenever you need to bind-mount a directory for a container that runs as UID 1000, you know exactly what to chown.
Note: Only do this on a fresh Docker install with no existing volumes. Changing the subuid after volumes exist will break file ownership inside those volumes.
Let's try bind mounts with nginx.
Step 1: Create a Directory on Your Host
$ mkdir -p data/dtnginx
Step 2: Create a Custom HTML File
$ vi data/dtnginx/index.html
Add this:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Docker DT Nginx</title>
</head>
<body>
<h2>Hello from DT Nginx container</h2>
</body>
</html>
Step 3: Run the Container with a Volume Mount
$ docker run -d --rm \
-v ./data/dtnginx:/usr/share/nginx/html \
--name dtnginx \
nginx
Let me break down the -v flag:
- Format is: host_path:container_path
- ./data/dtnginx — your host directory
- /usr/share/nginx/html — nginx's web root inside the container
The contents of your host directory replace the default nginx page.
Step 4: Test It
We do not have port mapping set up yet, so we test from inside the container:
$ docker exec dtnginx curl localhost
You should see your custom HTML:
<h2>Hello from DT Nginx container</h2>
Now here's the magic ✨
Edit the file on your host:
$ vi data/dtnginx/index.html
Change it to:
<h2>Updated content — no restart needed!</h2>
Test again:
$ docker exec dtnginx curl localhost
The change appears instantly, with no container restart required. 🚀
Bind mounts are live. Edit on the host → updates in the container immediately.
Let's inspect this one too:
$ docker inspect dtnginx | less
Search for Mounts and you will see:
"Mounts": [
{
"Type": "bind",
"Source": "/home/<username>/data/dtnginx",
"Destination": "/usr/share/nginx/html",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
]
Same structure as before — source on the host, destination inside the container — but the Type is now bind instead of volume. The source path will reflect your actual machine, so it will look different on your host.
Clean up:
$ docker stop dtnginx
This is perfect for: - 🌐 Web content - ⚙️ Configuration files - 💻 Development code
Bind Mounts vs Named Volumes
Now that you have seen both in action, here is when to use each:
| Type | Best For |
|---|---|
| 📂 Bind mounts | Web content, configs — when you need direct access to files on your host |
| 💾 Named volumes | Databases, build caches — when Docker should manage the storage for you |
The key difference is control. With bind mounts, you choose exactly where the files live on your host. With named volumes, Docker manages that for you — you just give it a name.
🔄 Common Volume Patterns
Here are three patterns you will use all the time.
Pattern 1: Development Workflow
Mount your source code for live editing:
$ docker run -d --rm \
-p 8080:80 \
-v ./myapp:/usr/share/nginx/html \
--name myapp-dev \
nginx
Edit HTML on your host → changes appear in the container → page updates automatically.
Pattern 2: Database with Named Volume
Let Docker manage the data storage:
$ docker run -d --rm \
--name dtredis \
-v redisdata:/data \
redis
Data survives even when you remove the container.
Pattern 3: Shared Build Cache
Speed up builds by caching dependencies:
$ docker run -d --rm \
-v maven-cache:/root/.m2 \
--name maven-build \
maven:latest mvn clean package
Subsequent builds reuse the cached dependencies.
🏋️ Exercise
Part 1: Upgrade Redis 8.4 → 8.6 Without Losing Data or Config
You are running Redis 8.4 in production with live data. Redis 8.6 is out — and this time you also want to add a maxmemory limit on upgrade. Can you do it without losing your data?
Your tasks:
- Run Redis 8.4 with a named volume for data — no config file yet
- Connect with
docker exec -itand redis-cli, then set a key - Stop the container and confirm it is gone with
docker ps -a - Create a
redis.confon your host withmaxmemory 100mb - Start Redis 8.6 with the same named volume AND a bind mount for the config
- Hint: you can pass multiple
-vflags - Hint: Redis needs to be told to load the config file on startup — check hub.docker.com/_/redis for how
- Connect with redis-cli and verify your key is still there
- Verify
maxmemoryis now applied
Try it yourself before reading the solution below.
Part 2: Connect RedisInsight to Your Redis Container
Your Redis data is safe in a named volume and your config is applied. Now let's go one step further — pre-configure RedisInsight to know about dtredis86 before it even starts. RedisInsight supports this via a JSON file pointed to by the RI_PRE_SETUP_DATABASES_PATH environment variable.
Your tasks:
- Keep
dtredis86running from Part 1 (or start it again without the cleanup step) - Create a
data/ri/directory, set permissions so RedisInsight can write to it, and write thisconfig.jsoninside it:json [ { "id": "<uuidgen output>", "name": "dtredis86", "host": "dtredis86", "port": 6379, "db": 0 } ]Replace<uuidgen output>with the result of runninguuidgen - Run RedisInsight with:
- A bind mount of
./data/rito/data RI_PRE_SETUP_DATABASES_PATH=/data/config.jsonRI_ACCEPT_TERMS_AND_CONDITIONS=true(required — pre-setup only runs after EULA is accepted)- No port mapping
- Try opening
http://localhost:5540in your browser — what happens? - Exec into the RedisInsight container (
sh) and confirm the app is running from inside: - Hint: try
wget -O - 127.0.0.1:5540 - While you're in there, try reaching Redis at
dtredis86on port6379 - Hint: try
nc dtredis86 6379, then find the Redis container's IP withdocker inspect dtredis86 | less(search/Networks) and tryecho "PING" | nc <ip> 6379
Try it yourself before reading the solution below.
Solutions
Part 1:
Start Redis 8.4 with only a named volume — no config:
$ docker run -d --rm \
--name dtredis84 \
-v redisdata:/data \
redis:8.4
Connect with redis-cli and set a key:
$ docker exec -it dtredis84 redis-cli
127.0.0.1:6379> SET mykey "still here?"
OK
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
127.0.0.1:6379> exit
Stop the container and confirm it is gone:
$ docker stop dtredis84
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
maxmemory is 0 — no limit. Redis 8.4 is running with default settings and no config file.
Now upgrade to 8.6 and introduce a maxmemory limit at the same time. The named volume carries the data across; the bind-mounted config file adds the new constraint:
$ echo "maxmemory 100mb" > redis.conf
$ docker run -d --rm \
--name dtredis86 \
-v redisdata:/data \
-v ./redis.conf:/etc/redis/redis.conf \
redis:8.6 redis-server /etc/redis/redis.conf
Connect with redis-cli and check everything:
$ docker exec -it dtredis86 redis-cli
127.0.0.1:6379> GET mykey
"still here?"
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "104857600"
127.0.0.1:6379> exit
The key survived the upgrade. The config applied fresh on 8.6. You added a new constraint without losing a single byte of data. 🎉
Part 2:
Create the config file and run RedisInsight:
$ mkdir -p data/ri
RedisInsight requires a UUID for each connection entry. We use $(uuidgen) to generate one inline — the shell expands it when the file is written, so you end up with a real UUID without having to type one:
$ cat > data/ri/config.json << EOF
[
{
"id": "$(uuidgen)",
"name": "dtredis86",
"host": "dtredis86",
"port": 6379,
"db": 0
}
]
EOF
$ sudo chown -R 1001000:1001000 data/ri
$ docker run -d --rm \
--name redisinsight \
-v ./data/ri:/data \
-e RI_PRE_SETUP_DATABASES_PATH=/data/config.json \
-e RI_ACCEPT_TERMS_AND_CONDITIONS=true \
redis/redisinsight
RedisInsight is pre-configured to connect to dtredis86 — the connection details live in a bind-mounted config file on your host, pointed to by an environment variable.
Give it a moment to start, then check what's in the directory:
$ ls -l data/ri
total 328
drwxr-xr-x 2 1001000 1001000 4096 Mar 26 21:38 commands
-rw-rw-r-- 1 1001000 1001000 146 Mar 26 21:36 config.json
drwxr-xr-x 2 1001000 1001000 4096 Mar 26 21:38 content
drwxr-xr-x 2 1001000 1001000 4096 Mar 26 21:38 logs
drwxr-xr-x 2 1001000 1001000 4096 Mar 26 21:38 plugins
-rw-r--r-- 1 1001000 1001000 311296 Mar 26 21:38 redisinsight.db
drwxr-xr-x 6 1001000 1001000 4096 Mar 26 21:38 tutorials
RedisInsight has written its own app data back to the bind-mounted directory — the bind mount works both ways. You wrote config.json in; RedisInsight wrote its database, logs, and plugins out. Notice the UID 1001000 on all files — that is container UID 1000 mapped to your host through the subuid range we set up earlier.
Try opening http://localhost:5540 — nothing loads. No port mapping, so your host has no way in.
But is the app actually running? Exec in and check:
$ docker exec -it redisinsight sh
# wget -O - 127.0.0.1:5540
<!doctype html>...
The app is alive inside the container. Now try reaching Redis by hostname:
# nc dtredis86 6379
nc: bad address 'dtredis86'
Hostname doesn't resolve. ❌ But on the default bridge network, containers can still reach each other by IP. Exit the container and find the Redis IP:
$ docker inspect dtredis86 | less
Search for Networks by typing /Networks and pressing Enter:
"Networks": {
"bridge": {
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
...
}
}
Exec back in and try the IP directly:
$ docker exec -it redisinsight sh
# echo "PING" | nc 172.17.0.2 6379
+PONG
Redis responds — the connection works by IP. But manually inspecting containers to find their IP every time is not how you want to run things. There has to be a better way.
Tip: You can also pre-configure connections using environment variables directly, skipping the JSON file entirely:
bash -e RI_REDIS_HOST=dtredis86 \ -e RI_REDIS_PORT=6379 \ -e RI_REDIS_ALIAS=dtredis86 \ -e RI_ACCEPT_TERMS_AND_CONDITIONS=true
Clean up:
$ docker stop dtredis86 redisinsight
$ docker volume rm redisdata
That is exactly what we cover next. 👉
👉 Coming up: Now that your data persists, the next step is connecting your containers. We will cover Docker networking, from bridge networks to port mapping and container-to-container communication.
📚 Want More?
This guide covers the basics from Chapter 8: Docker Persistent Volumes in my book, "Levelling Up with Docker" — 14 chapters of practical, hands-on Docker guides.
📚 Grab the book: "Levelling Up with Docker" on Amazon
Found this helpful? 🙌
- LinkedIn: Share with your network
- Twitter: Tweet about it
- Questions? Drop a comment below or reach out on LinkedIn