Skip to content

Storage

Stop and restart a container running a database with no volume or bind mount attached, and the data is gone — not corrupted, not recoverable, just gone, because it lived in a layer that no longer exists. This page covers how data survives (or doesn't) past a container's lifetime: why each storage flavour behaves the way it does, and the SELinux relabel that trips up almost everyone the first time. (The main course page shows the three storage flavours by syntax.)

TL;DR — in 30 seconds:

  • A container's writable layer is throwaway — it's deleted with the container, so data you care about has to live outside it: a named volume, a bind mount, or tmpfs.
  • Named volumes are Podman-managed and get the right SELinux label automatically; bind mounts are host paths you own and must label yourself with :Z/:z.
  • :Z (private) vs :z (shared) controls whether one container or many can access a relabeled host path — get it backwards and you either lock out or over-expose the path.

1. A container's own filesystem is throwaway

Every running container gets one writable layer stacked on top of its read-only image layers (see the images & builds page for the layer stack itself). Anything the process writes — a log file, a temp file, a database's data directory — lands in that writable layer. podman rm deletes the container and its writable layer together. Nothing written there survives a stop && rm, an image upgrade, or a crash-and-recreate.

That's fine for stateless work. It's a problem the moment a container holds data you can't afford to lose — a database's data directory being the canonical example. Storage that must outlive the container has to live outside that writable layer, which is what volumes and bind mounts are for.

flowchart LR
    subgraph container["Container"]
        WL["Writable layer<br/>(deleted with the container)"]
    end
    subgraph outside["Outside the container lifecycle"]
        V["Named volume<br/>(Podman-managed path)"]
        B["Bind mount<br/>(host path you chose)"]
        T["tmpfs<br/>(RAM, gone on stop)"]
    end
    WL -. "everything NOT mounted" .-> WL
    V -- "mounted into the container" --> container
    B -- "mounted into the container" --> container
    T -- "mounted into the container" --> container

2. Named volumes vs. bind mounts

Both put data outside the writable layer; they differ in who owns the path.

A named volume (podman volume create pgdata, then -v pgdata:/var/lib/postgresql/data) is a directory Podman creates and tracks under its own storage root. You never need to know or care where that directory actually lives on disk — you refer to it by name, and Podman handles placement, permissions, and (as covered below) the SELinux label.

A bind mount (-v /opt/app/data:/data) maps a host path you chose directly into the container. You own the path; Podman just mounts it. That makes bind mounts the right tool when a human or another host process needs to read or write the same files outside any container — config you edit by hand, a directory another service also watches — but it also means you're responsible for the path existing, being backed up, and (on RHEL) being labeled correctly for SELinux.

Named volume Bind mount
Who owns the path Podman You (the host)
Where it lives Podman's storage root; opaque Wherever you point it
Typical use Database data, anything Podman-managed Config files, shared host data, dev source trees
SELinux labeling Handled automatically Needs :Z or :z yourself
Portability Same volume name works on any host Host path must exist and match

3. The :Z / :z SELinux relabel

On a RHEL host, every file carries an SELinux context in addition to normal Unix permissions, and containers run under a confined process label (container_t) that can only touch files labeled for containers. A host path you bind-mount in generally does not carry that label — mount it as-is and the container gets EACCES on it, even though ls -l and chmod look completely fine. That mismatch, not a permissions bug, is the usual cause of "permission denied" on a bind mount that looks correctly owned.

The :Z and :z suffixes on -v tell Podman to relabel the host path for you (documented in the podman run volume options):

  • :Z (capital) — relabel with a private label. Only this container can access the path. Use it for a path that belongs to one container, which is the common case.
  • :z (lowercase) — relabel with a shared label. Any container can access the path. Use it only when you deliberately want several containers reading or writing the same host directory — applying it to a path like /etc/somefile would open that file to every container on the host, not just the ones that need it.
# Private: only this container touches /opt/app/data
podman run -v /opt/app/data:/data:Z myimage

# Shared: multiple containers touch /opt/shared together
podman run -v /opt/shared:/data:z myimage

The relabel is persistent — it rewrites the label on the host path itself, not just for the lifetime of the mount. Named volumes never need this: Podman creates them with the right label from the start, which is one more reason to prefer a named volume over a bind mount for data only a container should touch.

4. tmpfs — the third flavour

A tmpfs mount (--tmpfs /tmp:size=64m,mode=1777) is backed by RAM, not disk. It's the fastest option and needs no SELinux relabel, but its contents are gone the moment the container stops — not just removed, genuinely never written to disk. Reach for it for scratch space and caches, never for anything you'd mind losing on a restart.

5. Data persistence in practice

Because a named volume lives outside the container, the sequence that matters for a stateful service like a database is: create the volume once, then recreate the container against it as many times as you like.

podman volume create pgdata
podman run -d --name postgres -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret docker.io/library/postgres:16-alpine

# Later: replace the container, keep the data
podman stop postgres && podman rm postgres
podman run -d --name postgres -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret docker.io/library/postgres:16-alpine

pgdata never went away — only the container did. To back a volume up or move it to another host without a registry, mount it into a disposable container and archive it from there:

podman run --rm -v pgdata:/data:Z -v "$PWD":/backup:Z docker.io/library/alpine \
  tar czf /backup/pgdata.tar.gz -C /data .

Reverse the direction (extract into a fresh volume mounted the same way) to restore. The pattern — mount the volume into a throwaway container that has the tools you need — works for backup, restore, or just inspecting a volume's contents without touching the service that normally owns it.

6. How this connects

  • The writable-layer model here is the same overlay-plus-writable-layer picture from the images & builds page — storage is the flip side of the layer stack, not a separate mechanism.
  • :Z/:z is one instance of a broader RHEL pattern: SELinux enforces confinement by label, not by Unix permissions alone — the same reasoning shows up anywhere a process is denied access despite chmod looking correct.
  • Named volumes being Podman-managed and portable-by-name is the same idea Kubernetes takes further with PersistentVolumes and PersistentVolumeClaims — a stable handle to storage that outlives any one workload instance.

Common gotchas

  • Running a stateful service (a database) with no volume attached. Fix: podman rm deletes the writable layer with it — attach a named volume before you write data you'd mind losing.
  • Bind-mounting a host path on RHEL and skipping :Z/:z. Fix: an EACCES on a path that looks correctly chmod'd is almost always a missing SELinux relabel, not a Unix-permissions bug.
  • Using :z (shared) on a path only one container should touch. Fix: default to :Z (private) — :z opens the path to every container on the host, which is rarely what you want.
  • Reaching for a bind mount when a named volume would do. Fix: prefer a named volume for anything Podman-managed (like database data) — it's portable by name and never needs a manual relabel.
Check yourself — you stop and remove a container, then start a fresh one with the same named volume. What survives, and why?

The data survives — the named volume lives outside the container's writable layer and outside the container's lifecycle entirely. Only the container (and its throwaway writable layer) was deleted; the volume Podman tracks separately is untouched.

Check yourself — when do you reach for :z (lowercase) instead of :Z?

Only when you deliberately want more than one container to read or write the same host directory — :z applies a shared SELinux label that any container can access. For a path that belongs to a single container, :Z (private) is correct and safer.

You can defend this when you can explain why a container's own filesystem is unsafe for data you care about, why a bind mount that looks correctly chmod'd can still fail with EACCES on RHEL, and when you'd reach for :Z versus :z.