06 April 2026

docker devops linux tutorial

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


💾 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:

  1. Run Redis 8.4 with a named volume for data — no config file yet
  2. Connect with docker exec -it and redis-cli, then set a key
  3. Stop the container and confirm it is gone with docker ps -a
  4. Create a redis.conf on your host with maxmemory 100mb
  5. Start Redis 8.6 with the same named volume AND a bind mount for the config
  6. Hint: you can pass multiple -v flags
  7. Hint: Redis needs to be told to load the config file on startup — check hub.docker.com/_/redis for how
  8. Connect with redis-cli and verify your key is still there
  9. Verify maxmemory is 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:

  1. Keep dtredis86 running from Part 1 (or start it again without the cleanup step)
  2. Create a data/ri/ directory, set permissions so RedisInsight can write to it, and write this config.json inside it: json [ { "id": "<uuidgen output>", "name": "dtredis86", "host": "dtredis86", "port": 6379, "db": 0 } ] Replace <uuidgen output> with the result of running uuidgen
  3. Run RedisInsight with:
  4. A bind mount of ./data/ri to /data
  5. RI_PRE_SETUP_DATABASES_PATH=/data/config.json
  6. RI_ACCEPT_TERMS_AND_CONDITIONS=true (required — pre-setup only runs after EULA is accepted)
  7. No port mapping
  8. Try opening http://localhost:5540 in your browser — what happens?
  9. Exec into the RedisInsight container (sh) and confirm the app is running from inside:
  10. Hint: try wget -O - 127.0.0.1:5540
  11. While you're in there, try reaching Redis at dtredis86 on port 6379
  12. Hint: try nc dtredis86 6379, then find the Redis container's IP with docker inspect dtredis86 | less (search /Networks) and try echo "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? 🙌

← Docker Environment Management: Images, Logs, and Cleanup (2026 Guide) Docker Networking Explained: Connect Your Containers (2026) →