DEV Community

David Tio
David Tio

Posted on • Originally published at blog.dtio.app

Persistent Volumes: Data That Survives Container Death (2026)

๐Ÿง Persistent Volumes: Data That Survives Container Death (2026)

Quick one-liner: Your containers are ephemeral. Your data shouldn't be. Learn Podman volumes and bind mounts โ€” the two ways to make data survive container death.


๐Ÿค” Why This Matters

Last time, you ran Redis and PostgreSQL. You inserted data, verified it worked, then stopped and removed the containers.

And Alice disappeared.

When a container dies, everything inside it โ€” files, databases, configuration โ€” dies with it. That's by design. Containers are ephemeral. They're meant to be disposable.

But your data isn't disposable. Your PostgreSQL database, your application files, your user uploads โ€” they need to outlive the container that serves them.

Since we are in levelling podman universe, we are allowed to make mistakes and go back in time to make things right. Let's go back and save Alice.


โœ… Prerequisites

  • Ep 1-2 completed. Podman installed, you can run and manage containers.
  • jq installed. Run sudo zypper in jq to install it. It's used throughout this episode to parse JSON output cleanly.

โช Time Machine: Let's Save Alice

Rewind to the moment before you created dtpg2. This time, create a volume first:

$ podman volume create pgdata
pgdata
Enter fullscreen mode Exit fullscreen mode

Now start PostgreSQL with the volume attached:

$ podman run -d --rm --name dtpg \
    -e POSTGRES_PASSWORD=podman \
    -e POSTGRES_DB=testdb \
    -v pgdata:/var/lib/pgsql/data \
    suse/postgres
Enter fullscreen mode Exit fullscreen mode

That -v pgdata:/var/lib/pgsql/data flag is the magic. It mounts our pgdata volume into PostgreSQL's data directory. Everything PostgreSQL writes goes straight to the volume, not the container's ephemeral filesystem.

Verify the volume is mounted:

$ podman inspect dtpg --format '{{json .Mounts}}' | jq
[
  {
    "Type": "volume",
    "Name": "pgdata",
    "Source": "/var/lib/containers/storage/sysadmin/volumes/pgdata/_data",
    "Destination": "/var/lib/pgsql/data",
    "Driver": "local",
    "Mode": "",
    "Options": [
      "nosuid",
      "nodev",
      "rbind"
    ],
    "RW": true,
    "Propagation": "rprivate"
  }
]
Enter fullscreen mode Exit fullscreen mode

The volume is mounted at /var/lib/pgsql/data inside the container โ€” where PostgreSQL stores its actual data. On the host, it's at /var/lib/containers/storage/sysadmin/volumes/pgdata/_data thanks to the graphroot setting from Ep 1.

Wait ~10 seconds for PostgreSQL to initialize, then create Alice:

$ podman exec -it dtpg psql -U postgres -d testdb
Enter fullscreen mode Exit fullscreen mode
testdb=# CREATE TABLE users (name VARCHAR(50), email VARCHAR(50));
CREATE TABLE
testdb=# INSERT INTO users VALUES ('Alice', 'alice@example.com');
INSERT 0 1
testdb=# SELECT * FROM users;
  name  |       email
--------+--------------------
 Alice  | alice@example.com
(1 row)
testdb=# \q
Enter fullscreen mode Exit fullscreen mode

Alice is back!!!

But this is Final Destination โ€” we need to kill the container again. Stop and remove the container:

$ podman stop dtpg
Enter fullscreen mode Exit fullscreen mode

The container is gone, but the volume is still there:

$ podman volume ls
DRIVER     VOLUME NAME
local      pgdata
Enter fullscreen mode Exit fullscreen mode

Start a brand new container with the same volume:

$ podman run -d --rm --name dtpg2 \
    -e POSTGRES_PASSWORD=podman \
    -e POSTGRES_DB=testdb \
    -v pgdata:/var/lib/pgsql/data \
    suse/postgres
Enter fullscreen mode Exit fullscreen mode

Let's look for Alice:

$ podman exec -it dtpg2 psql -U postgres -d testdb -c "SELECT * FROM users;"
  name  |       email
--------+--------------------
 Alice  | alice@example.com
(1 row)
Enter fullscreen mode Exit fullscreen mode

She is officially immortal now!!!

That pgdata volume is where Alice lives now. It's called a Named Volume โ€” Podman manages the storage location, you just give it a name.

This is how you run databases in containers. The container is disposable. The data isn't.


๐Ÿ“ Bind Mounts: The Picture and the Frame

Named volumes are like Alice โ€” the data survives inside the container. But there's another way to think about persistence.

Imagine your website is a picture. The nginx container is just a frame that displays it.

Create your website directory:

$ mkdir -p ~/mywebsite
$ echo "<h1 style='background:pink;padding:20px;'>Alice on the Beach ๐Ÿ–๏ธ</h1>" > ~/mywebsite/index.html
Enter fullscreen mode Exit fullscreen mode

Start nginx with a bind mount to your website:

$ podman run -d --rm --name dtweb \
    -v ~/mywebsite:/srv/www/htdocs:Z \
    suse/nginx
Enter fullscreen mode Exit fullscreen mode

Three things to note about this command:

  1. The path starts with ~ or / โ€” Podman knows this is a bind mount, not a named volume.
  2. The :Z suffix โ€” tells Podman to relabel the directory with the right SELinux security context so the container can read and write to it. On SLES, SELinux is enabled by default, so this matters.
  3. The rbind option โ€” recursive bind mount. If the source directory has subdirectories, they're also mounted into the container.

Verify the bind mount is set up correctly:

$ podman inspect dtweb --format '{{json .Mounts}}' | jq
[
  {
    "Type": "bind",
    "Source": "/home/sysadmin/mywebsite",
    "Destination": "/srv/www/htdocs",
    "Driver": "",
    "Mode": "",
    "Options": [
      "rbind"
    ],
    "RW": true,
    "Propagation": "rprivate"
  }
]
Enter fullscreen mode Exit fullscreen mode

You can see your bind mount there โ€” ~/mywebsite mapped to /srv/www/htdocs inside the container.

Verify it's working by curling from inside the container:

$ podman exec dtweb curl -s http://localhost/
<h1 style='background:pink;padding:20px;'>Alice on the Beach ๐Ÿ–๏ธ</h1>
Enter fullscreen mode Exit fullscreen mode

Now update the picture without touching the frame:

$ echo "<h1 style='background:skyblue;padding:20px;'>Alice Hiking Mount Everest ๐Ÿ”๏ธ</h1>" > ~/mywebsite/index.html
Enter fullscreen mode Exit fullscreen mode

Verify the change from inside the container:

$ podman exec dtweb curl -s http://localhost/
<h1 style='background:skyblue;padding:20px;'>Alice Hiking Mount Everest ๐Ÿ”๏ธ</h1>
Enter fullscreen mode Exit fullscreen mode

You can swap frames anytime. Kill the nginx container, start a new one, your picture is still on your host:

$ podman stop dtweb
$ podman ps -a
CONTAINER ID  IMAGE  CREATED  STATUS  PORTS  NAMES
Enter fullscreen mode Exit fullscreen mode

The container is gone. Check the bind mount directory on the host:

$ ls ~/mywebsite
index.html
Enter fullscreen mode Exit fullscreen mode

The picture survives. The frame is disposable.


๐Ÿ†š Named Volumes vs Bind Mounts: When to Use Each

Feature Named Volumes Bind Mounts
Storage location Podman picks it You pick it
SELinux handling Automatic Needs :Z suffix
Bind options N/A rbind by default (recursive)
Browse on host Yes, but deep path Direct, familiar path
Update without restart No Yes
Best for Databases, app data Web development, config files

For databases, named volumes are the default choice โ€” Podman manages the permissions and SELinux labels, you don't have to think about it.

For web development or when you need direct access to files from your host, bind mounts let you edit code without rebuilding containers.


๐Ÿงน Cleaning Up

Named volumes don't auto-delete. Even with --rm on the container, the volume stays behind:

$ podman stop dtpg2
$ podman volume ls
DRIVER     VOLUME NAME
local      pgdata
Enter fullscreen mode Exit fullscreen mode

Remove a named volume:

$ podman volume rm pgdata
pgdata
Enter fullscreen mode Exit fullscreen mode

Remove all unused volumes:

$ podman volume prune
WARNING! This will remove all local volumes not used by at least one container.
Are you sure you want to continue? [y/N] y
Enter fullscreen mode Exit fullscreen mode

Be careful with volume prune โ€” if you have volumes holding data you haven't backed up, they're gone.

Bind mounts are not affected by volume prune. They live on your host filesystem, not in Podman's volume storage.


๐Ÿ“œ Container Logs: Seeing What Your Containers Are Doing

Your containers are running. You have persistent storage. But what if something goes wrong? How do you debug a misbehaving container?

Containers generate output โ€” web servers log requests, databases log queries, your app logs errors. You need to see that output to debug problems.

Start a fresh nginx container to observe:

$ podman run -d --rm --name dtweb \
    -v ~/mywebsite:/srv/www/htdocs:Z \
    suse/nginx
Enter fullscreen mode Exit fullscreen mode

The podman logs command fetches everything the container has written to stdout and stderr:

$ podman logs dtweb
/usr/local/bin/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/usr/local/bin/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/usr/local/bin/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/usr/local/bin/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/usr/local/bin/docker-entrypoint.sh: Configuration complete; ready for start up
Enter fullscreen mode Exit fullscreen mode

You see the startup sequence. If something goes wrong, you'd see the error messages here.

Three flags you'll use constantly:

Flag What It Does
--tail N Show only the last N lines
--follow Stream logs in real time (like tail -f)
--since TIME Show logs since a timestamp

The --follow flag is particularly useful during development. Curl a few times and tail the logs in another terminal:

Terminal 1: $ podman logs --follow dtweb
Terminal 2: $ podman exec dtweb curl -s http://localhost/
Terminal 2: $ podman exec dtweb curl -s http://localhost/test.txt
Enter fullscreen mode Exit fullscreen mode

You see successful requests and 404 errors in real time:

127.0.0.1 - - [20/Apr/2026:02:29:09 +0000] "GET / HTTP/1.1" 200 85 "-" "curl/8.14.1"
127.0.0.1 - - [20/Apr/2026:02:29:16 +0000] "GET / HTTP/1.1" 200 85 "-" "curl/8.14.1"
2026/04/20 02:29:20 [error] 19#19: *4 open() "/srv/www/htdocs/test.txt" failed (2: No such file or directory), client: 127.0.0.1, server: localhost, request: "GET /test.txt HTTP/1.1", host: "localhost"
127.0.0.1 - - [20/Apr/2026:02:29:20 +0000] "GET /test.txt HTTP/1.1" 404 153 "-" "curl/8.14.1"
Enter fullscreen mode Exit fullscreen mode

Each curl request appears in the log in real time. When you're done, press Ctrl+C to stop watching.

By default, Podman sends logs to the system journal (journald), not to files. Check the logging configuration on your running container:

$ podman inspect dtweb --format '{{.HostConfig.LogConfig}}'
{journald map[]   0B}
# Raw Go struct output โ€” "journald" is the log driver in use
Enter fullscreen mode Exit fullscreen mode

The default is journald logging. It doesn't have size issues because the system journal handles rotation.

You can also use journalctl to filter by container:

$ journalctl CONTAINER_NAME=dtweb
Enter fullscreen mode Exit fullscreen mode

Or follow logs in real time:

$ journalctl CONTAINER_NAME=dtweb -f
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‹๏ธ Exercise 1: Upgrade Redis with Data Survival

You are running Redis 8.4 in production with live data. Redis 8.6 is out โ€” can you upgrade without losing your data?

Your tasks:

  1. Run Redis 8.4 with a named volume for data
  2. Connect with podman exec -it and redis-cli, then add a key
  3. Stop the container and confirm it is gone with podman ps -a
  4. Start Redis 8.6 with the same named volume
  5. Connect with redis-cli and verify your key is still there

Try it yourself before reading the solution below.

Hints:

  • Use docker.io/library/redis:8.4 for Redis 8.4 and docker.io/library/redis:8.6 for Redis 8.6
  • Use a named volume (e.g., -v redisdata:/data)

Exercise 1 Walkthrough

Run Redis 8.4 with a named volume, add data, then upgrade to 8.6:

$ podman volume create redisdata
$ podman run -d --rm --name dtrs \
    -v redisdata:/data \
    docker.io/library/redis:8.4
Enter fullscreen mode Exit fullscreen mode

Add some data:

$ podman exec -it dtrs redis-cli
127.0.0.1:6379> SET greeting "Hello from Redis 8.4"
OK
127.0.0.1:6379> GET greeting
"Hello from Redis 8.4"
127.0.0.1:6379> EXIT
Enter fullscreen mode Exit fullscreen mode

Stop the container and confirm it's gone:

$ podman stop dtrs
$ podman ps -a
CONTAINER ID  IMAGE  CREATED  STATUS  PORTS  NAMES
Enter fullscreen mode Exit fullscreen mode

Start Redis 8.6 with the same volume:

$ podman run -d --rm --name dtrs \
    -v redisdata:/data \
    docker.io/library/redis:8.6
Enter fullscreen mode Exit fullscreen mode

Check if data survived:

$ podman exec dtrs redis-cli GET greeting
Hello from Redis 8.4
Enter fullscreen mode Exit fullscreen mode

Data survived! Clean up:

$ podman stop dtrs
$ podman volume rm redisdata
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‹๏ธ Exercise 2: CloudBeaver + MariaDB โ€” The Networking Problem

You want to run MariaDB with your data, then manage it with CloudBeaver. Can you access the CloudBeaver web UI and connect it to MariaDB?

Your tasks:

  1. Run MariaDB with a named volume and create a table with some data
  2. Run CloudBeaver (a web-based database GUI)
  3. Try to access the CloudBeaver web UI in your browser โ€” what happens?
  4. Try to connect CloudBeaver to MariaDB โ€” what happens?

Try it yourself before reading the solution below.

Hints:

  • Use suse/mariadb for MariaDB
  • Use docker.io/dbeaver/cloudbeaver:latest for CloudBeaver
  • By default, containers cannot reach each other or the outside world.

Exercise 2 Walkthrough

Let's try something more interesting. Run MariaDB with a named volume:

$ podman volume create dbdata
$ podman run -d --rm --name dtmaria \
    -e MYSQL_ROOT_PASSWORD=podman \
    -e MYSQL_DATABASE=testdb \
    -v dbdata:/var/lib/mysql \
    suse/mariadb
Enter fullscreen mode Exit fullscreen mode

Wait for MariaDB to start. Create a table:

$ podman exec -it dtmaria mariadb -u root -ppodman testdb
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE pets (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
INSERT INTO pets (name) VALUES ('Whiskers'), ('Buddy');
SELECT * FROM pets;
exit
Enter fullscreen mode Exit fullscreen mode

Now run CloudBeaver:

$ podman run -d --rm --name dtcloud \
    -e DATABASE_DB=mariadb \
    docker.io/dbeaver/cloudbeaver:latest
Enter fullscreen mode Exit fullscreen mode

You can't access the CloudBeaver web UI, and even if you could, it still couldn't connect to MariaDB. The containers can't reach each other because there's no network connecting them.

For now, clean up:

$ podman stop dtmaria dtcloud
$ podman volume rm dbdata
Enter fullscreen mode Exit fullscreen mode

๐Ÿš€ What's Next

But right now your containers are invisible to the outside world. We end up with 2 running containers that are quite useless. Let's get them connected in the next episode.

See you then.


Found this helpful? ๐Ÿ™Œ

Top comments (0)