๐ง 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 jqto 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
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
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"
}
]
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
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
Alice is back!!!
But this is Final Destination โ we need to kill the container again. Stop and remove the container:
$ podman stop dtpg
The container is gone, but the volume is still there:
$ podman volume ls
DRIVER VOLUME NAME
local pgdata
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
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)
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
Start nginx with a bind mount to your website:
$ podman run -d --rm --name dtweb \
-v ~/mywebsite:/srv/www/htdocs:Z \
suse/nginx
Three things to note about this command:
-
The path starts with
~or/โ Podman knows this is a bind mount, not a named volume. -
The
:Zsuffix โ 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. -
The
rbindoption โ 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"
}
]
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>
Now update the picture without touching the frame:
$ echo "<h1 style='background:skyblue;padding:20px;'>Alice Hiking Mount Everest ๐๏ธ</h1>" > ~/mywebsite/index.html
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>
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
The container is gone. Check the bind mount directory on the host:
$ ls ~/mywebsite
index.html
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
Remove a named volume:
$ podman volume rm pgdata
pgdata
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
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
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
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
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"
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
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
Or follow logs in real time:
$ journalctl CONTAINER_NAME=dtweb -f
๐๏ธ 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:
- Run Redis 8.4 with a named volume for data
- Connect with
podman exec -itand redis-cli, then add a key - Stop the container and confirm it is gone with
podman ps -a - Start Redis 8.6 with the same named volume
- 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.4for Redis 8.4 anddocker.io/library/redis:8.6for 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
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
Stop the container and confirm it's gone:
$ podman stop dtrs
$ podman ps -a
CONTAINER ID IMAGE CREATED STATUS PORTS NAMES
Start Redis 8.6 with the same volume:
$ podman run -d --rm --name dtrs \
-v redisdata:/data \
docker.io/library/redis:8.6
Check if data survived:
$ podman exec dtrs redis-cli GET greeting
Hello from Redis 8.4
Data survived! Clean up:
$ podman stop dtrs
$ podman volume rm redisdata
๐๏ธ 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:
- Run MariaDB with a named volume and create a table with some data
- Run CloudBeaver (a web-based database GUI)
- Try to access the CloudBeaver web UI in your browser โ what happens?
- Try to connect CloudBeaver to MariaDB โ what happens?
Try it yourself before reading the solution below.
Hints:
- Use
suse/mariadbfor MariaDB - Use
docker.io/dbeaver/cloudbeaver:latestfor 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
Wait for MariaDB to start. Create a table:
$ podman exec -it dtmaria mariadb -u root -ppodman testdb
CREATE TABLE pets (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
INSERT INTO pets (name) VALUES ('Whiskers'), ('Buddy');
SELECT * FROM pets;
exit
Now run CloudBeaver:
$ podman run -d --rm --name dtcloud \
-e DATABASE_DB=mariadb \
docker.io/dbeaver/cloudbeaver:latest
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
๐ 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? ๐
- LinkedIn: Share with your network
- Questions? Drop a comment below or reach out on LinkedIn
Top comments (0)