How to Persist Keycloak Data in Docker and Podman

Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package podman-5.8.2-5.el10_2.x86_64
quay.io/keycloak/keycloak:26.7.3
docker.io/library/postgres:16
Applies to Any Linux host with Podman or Docker — volume paths and persistence rules are the same in the official Quay image
Privilege Normal user for rootless Podman; root when your site policy requires rootful containers
Scope Which Keycloak container volumes to persist for start-dev vs PostgreSQL-backed start, restart vs recreate behavior, what lives under /opt/keycloak/data, and a compact map of conf / providers / themes / data/import — not realm import labs, backup runbooks, or HA clustering
Related guides Install Keycloak with Docker
Install Keycloak with Podman
Short on time?Beta Get the gist of this 18-minute read in seconds, then ask follow-up questions

If you run Keycloak in Docker or Podman, the practical question is simple: which volume should I mount so my data survives?

The answer depends on your database:

  • If you run Keycloak with start-dev and its default built-in development database, persist /opt/keycloak/data.
  • If Keycloak uses PostgreSQL or another external database, your realms and users live in that database. Persist the database instead.

Restarting a container is different from removing and recreating it. This guide demonstrates both cases.

Using Docker? The run, rm, volume, network, and exec commands shown with Podman have equivalent Docker syntax — in these examples you can normally replace podman with docker. The :Z SELinux option shown on RHEL/Podman hosts is only needed where SELinux relabeling is required.


Which Keycloak data needs to persist?

The answer depends on how you start Keycloak:

How you run Keycloak Where persistent realm/user/client state lives What to persist
start-dev with built-in dev-file database H2 files under /opt/keycloak/data/h2 inside the container Mount /opt/keycloak/data with a named volume if you want dev data to survive docker rm / podman rm
start with PostgreSQL, MySQL, or another supported database Rows in the external database Persist the database itself — for example /var/lib/postgresql/data when PostgreSQL runs in the official postgres:16 container shown here; managed RDS, Cloud SQL, or an external DB VM uses its own storage and backup system

In start-dev, Keycloak uses its built-in development database, called dev-file. In this Keycloak version, that database is backed by H2 files stored under /opt/keycloak/data/h2. You do not need to install a separate database for this mode.

Mounting /opt/keycloak/data is not universally the Keycloak persistence solution. What you persist depends on whether you use local development storage or an external database.

This is why Keycloak persistence advice can look contradictory. A volume on /opt/keycloak/data makes sense for the embedded development database, while a PostgreSQL-backed deployment persists its identity data in PostgreSQL. Both recommendations can be correct — they apply to different database configurations.

Keycloak documents dev-file as the default development database and states it is not suitable for production. The database guide describes relational storage for user, client, and realm data.


How do I know which persistence method applies to me?

If you started Keycloak simply with:

bash
quay.io/keycloak/keycloak:26.7.3 start-dev

and did not configure KC_DB, this article's /opt/keycloak/data section applies to you.

If you configured variables such as:

text
KC_DB=postgres
KC_DB_URL=...

Keycloak is using PostgreSQL, so the database is your primary persistence layer.


Does restarting a Keycloak container delete data?

Stopping and starting the same container keeps its writable filesystem. Removing the container deletes unmounted data in the container layer.

Files written inside a container normally belong to that container's writable layer — in simple terms, its private filesystem. Removing the container removes those files unless they were written to storage outside the container, such as a volume.

Action What happens to files in the Keycloak container
docker restart / podman restart Survives
docker / podman stop → start (same container) Survives
Remove container → create a new container (no volume on /opt/keycloak/data) Writable container layer is gone — dev-file data lost
Remove container → recreate with the same named volume on /opt/keycloak/data Volume data survives — same volume name before the colon

When Keycloak uses PostgreSQL, realm and user data is not stored in this filesystem in the first place, so replacing only the Keycloak container does not remove those records.

Queries such as "Keycloak loses users after Docker restart" often confuse restart with recreate. A restart reuses the same container. docker rm / podman rm followed by a new run creates a fresh container unless you attached a named volume or external database.


Where does Keycloak store users, realms, and clients?

Think of the Keycloak container as the application and PostgreSQL as the persistent data store. Replacing the Keycloak container does not delete users when the new instance reconnects to the same database.

In a standard database-backed Keycloak deployment, persistent realm, user, and client data is stored in PostgreSQL rather than /opt/keycloak/data. That typically includes:

  • Realms and realm settings
  • Users, locally stored credentials, and user attributes
  • Clients, client scopes, and redirect URIs
  • Realm roles, client roles, and group membership
  • Identity-provider and federation configuration
  • Authentication flows and required actions

External user stores such as LDAP have their own persistence requirements. Operational data such as login sessions may also land in the database where applicable. Short-lived caches can exist in memory between restarts, but container recreation of the Keycloak container does not empty PostgreSQL when JDBC is configured.

See Configure Keycloak with PostgreSQL for a full JDBC setup. This article focuses on which component owns persistence when containers are replaced.


start-dev: persist /opt/keycloak/data

start-dev is Keycloak's development startup mode with development defaults, including the local dev-file database. start is the normal server mode used later in this article with PostgreSQL. The important persistence difference here is the database: embedded dev-file versus external PostgreSQL — not the literal startup command name alone.

Realm changes you make in the Admin Console are written into dev-file storage under /opt/keycloak/data/h2. That is why Stack Overflow answers and tutorials mount a volume on /opt/keycloak/data — the advice targets local start-dev, not external PostgreSQL.

Without a volume — data disappears on recreate

Remove any leftover lab container:

bash
podman rm -f kc-dev-no-volume 2>/dev/null || true

Start start-dev on port 8081 with no mount on /opt/keycloak/data:

bash
podman run -d \
  --name kc-dev-no-volume \
  -p 127.0.0.1:8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD='ChangeMe!2026' \
  quay.io/keycloak/keycloak:26.7.3 \
  start-dev

Wait until the Admin Console loads at http://127.0.0.1:8081/admin/ — first boot often takes 30–90 seconds. Sign in with the bootstrap admin credentials, open Manage realms, choose Create realm, and name it persistlab.

Remove the container:

bash
podman rm -f kc-dev-no-volume

-f stops the running container and removes it. This is deliberately different from podman restart; we are testing what happens when the container itself is replaced.

Run the same podman run command again. Because no volume was attached, the new container gets a fresh dev-file database (H2 storage) and persistlab no longer exists. The same behavior applies to docker rm followed by docker run when no volume is attached.

With a named volume — dev data survives recreate

A named volume is persistent storage managed by Docker or Podman and referenced by a name such as keycloak-persist-dev-data. Unlike a bind mount, you do not choose the host directory where its files are stored; the container engine manages that location for you.

When you remove a container, its writable layer disappears. A named volume keeps files outside that disposable layer so a new container can attach the same storage later.

Keycloak container
      |
      | writes /opt/keycloak/data
      v
named volume keycloak-persist-dev-data
      |
      v
survives when container is deleted

Mount the named volume on /opt/keycloak/data when you want start-dev labs to survive container recreation (removing the old container and creating a new one). The basic volume syntax is:

text
-v keycloak-persist-dev-data:/opt/keycloak/data
 └──── volume name ────────┘ └─ path inside container ─┘

In a named-volume mapping, the value before the colon identifies the Docker or Podman volume, while the value after the colon is the path inside the container.

This tells Podman: whenever Keycloak writes to /opt/keycloak/data inside the container, store those files in the persistent volume named keycloak-persist-dev-data.

On SELinux-enabled hosts such as RHEL, the examples add :Z so the container can access the mounted storage:

text
-v keycloak-persist-dev-data:/opt/keycloak/data:Z

We mount the documented Keycloak data directory rather than targeting its h2 implementation subdirectory directly. This keeps the mount aligned with Keycloak's runtime-data layout instead of coupling it only to the current dev-file implementation.

You do not need a separate setup step. The first time you use -v keycloak-persist-dev-data:/opt/keycloak/data, Podman creates the volume automatically if it does not exist yet.

Run the container with the named volume attached:

bash
podman run -d \
  --name kc-dev-volume \
  -p 127.0.0.1:8082:8080 \
  -v keycloak-persist-dev-data:/opt/keycloak/data:Z \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD='ChangeMe!2026' \
  quay.io/keycloak/keycloak:26.7.3 \
  start-dev

After the container starts, confirm that Podman created the volume:

bash
podman volume ls | grep keycloak-persist-dev-data

Sample output:

output
local       keycloak-persist-dev-data

Wait for the Admin Console at http://127.0.0.1:8082/admin/, then create a test realm such as volrealm.

Remove the container:

bash
podman rm -f kc-dev-volume

podman rm removes the container, not the named volume. The volume remains until you explicitly remove it — for example with podman volume rm keycloak-persist-dev-data.

Run the same podman run command again with -v keycloak-persist-dev-data:/opt/keycloak/data:Z:

bash
podman run -d \
  --name kc-dev-volume \
  -p 127.0.0.1:8082:8080 \
  -v keycloak-persist-dev-data:/opt/keycloak/data:Z \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD='ChangeMe!2026' \
  quay.io/keycloak/keycloak:26.7.3 \
  start-dev

Same volume means the volume name before the colon must remain keycloak-persist-dev-data. If you change it to another name, Podman creates or uses different storage and the old Keycloak data will not appear.

When the Admin Console is ready again, the test realm is still present because the dev-file database files live on the named volume, not in the disposable container layer. See Podman volumes for creating, inspecting, and removing named volumes in more detail.

Named volume or bind mount?

Both can provide persistence. The left side of -v means something different in each case:

Mapping Left side means
keycloak-persist-dev-data:/opt/keycloak/data Container-engine managed volume name
./keycloak-data:/opt/keycloak/data Actual host directory

Use a named volume when you simply want Docker or Podman to manage the persistent storage for you. Use a bind mount when you specifically want the files available at a host directory that you choose.

With a bind mount, the value before the colon is instead a directory path on the host:

bash
-v ./keycloak-data:/opt/keycloak/data:Z

Here, ./keycloak-data is an actual directory relative to your current working directory. Unlike a named volume, you choose and can directly access this host directory.

The Keycloak persistence rule is the same — the storage must exist outside the disposable container. On SELinux systems with Podman, account for the appropriate volume labeling (for example the :Z suffix shown above).

Docker Compose example for start-dev

If you use Docker Compose, the same development persistence setup looks like this:

yaml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.7.3
    command: start-dev
    ports:
      - "8080:8080"
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ChangeMe!2026
    volumes:
      - keycloak-data:/opt/keycloak/data

volumes:
  keycloak-data:

This is appropriate only for retaining a local start-dev environment, not for external database architecture. Podman Compose uses the same volume path inside the official image. External-database layouts are covered in Keycloak start vs start-dev and Configure Keycloak with PostgreSQL.


External PostgreSQL: persist the database, not the Keycloak container

Think of it this way: the Keycloak container runs the application, while PostgreSQL owns the important stored records. Replacing the application container is therefore safe as long as the new Keycloak instance reconnects to the same PostgreSQL database.

With Keycloak running in start mode and connected to PostgreSQL, treat the Keycloak container as replaceable and the database as the persistence layer. The sample commands below use plain HTTP on a non-default port for local testing — that is not itself a production-ready deployment.

/var/lib/postgresql/data is a path inside the PostgreSQL container, not the Keycloak container.

Keycloak container (kc-postgres-app)     PostgreSQL container (kc-postgres)
---------------------------              ---------------------------
/opt/keycloak/data                       /var/lib/postgresql/data
       |                                          |
       | JDBC                                     v
       +--------------------------------> keycloak-pg-data volume

If PostgreSQL itself runs in a container, persist its /var/lib/postgresql/data directory for the PostgreSQL image and version shown here. If you use Amazon RDS, Azure Database, Cloud SQL, an external PostgreSQL VM, or another managed service, that platform's storage and backup system provides persistence instead.

Persistence is not backup. A volume keeps data when a container is replaced; it does not protect you if the volume itself is deleted, corrupted, or the host fails.

The database holds persistent Keycloak realm/user/client state. Keycloak itself needs network access and consistent KC_DB_* values on every recreate — not a matching data volume for that identity data.

Persistence-only Compose excerpt — not a complete runnable Keycloak configuration

This shortened example only shows where the persistent volume belongs. It is not a complete PostgreSQL + Keycloak deployment you can run with docker compose up as written. See Configure Keycloak with PostgreSQL for the complete Compose file.

yaml
services:
  postgres:
    image: postgres:16
    volumes:
      - keycloak-pg-data:/var/lib/postgresql/data

  keycloak:
    image: quay.io/keycloak/keycloak:26.7.3
    # Connect Keycloak to PostgreSQL with KC_DB_* settings

volumes:
  keycloak-pg-data:

Notice that only PostgreSQL has a data volume. There is intentionally no /opt/keycloak/data volume on the Keycloak service because this example stores persistent realm/user/client data in PostgreSQL.

Recreate only Keycloak — realm data stays in PostgreSQL

This test uses these objects:

Name Type Purpose
kc-persist-net Network Connects Keycloak and PostgreSQL by container name
kc-postgres Container PostgreSQL server
keycloak-pg-data Volume Persistent PostgreSQL data
kc-postgres-app Container Disposable Keycloak application

The user-defined network lets the Keycloak and PostgreSQL containers reach each other by container name. Because both containers join kc-persist-net, the hostname kc-postgres in the JDBC URL resolves to the PostgreSQL container. You therefore do not need to know PostgreSQL's container IP address.

Create the network:

bash
podman network create kc-persist-net 2>/dev/null || true

Start PostgreSQL with a named data volume. This uses the same named-volume pattern as before; the difference is which container path is persisted — this time /var/lib/postgresql/data belongs to PostgreSQL:

bash
podman run -d \
  --name kc-postgres \
  --network kc-persist-net \
  -v keycloak-pg-data:/var/lib/postgresql/data:Z \
  -e POSTGRES_DB=keycloak \
  -e POSTGRES_USER=keycloak \
  -e POSTGRES_PASSWORD='KcDb!2026' \
  docker.io/library/postgres:16

PostgreSQL does not need a host port such as -p 5432:5432 here because Keycloak reaches it directly over kc-persist-net. Publish PostgreSQL's port only if something on the host also needs direct database access.

The JDBC URL breaks down like this:

text
jdbc:postgresql://kc-postgres:5432/keycloak
                   └────┬────┘ └┬─┘ └──┬───┘
                 DB container  port  database
  • kc-postgres — PostgreSQL container hostname on the network
  • 5432 — PostgreSQL port inside the container network
  • keycloak — database created by POSTGRES_DB=keycloak

Start Keycloak on start with JDBC pointed at that database:

bash
podman run -d \
  --name kc-postgres-app \
  --network kc-persist-net \
  -p 127.0.0.1:8083:8080 \
  -e KC_HOSTNAME='http://localhost:8083' \
  -e KC_HTTP_ENABLED=true \
  -e KC_DB=postgres \
  -e KC_DB_URL='jdbc:postgresql://kc-postgres:5432/keycloak' \
  -e KC_DB_USERNAME=keycloak \
  -e KC_DB_PASSWORD='KcDb!2026' \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD='ChangeMe!2026' \
  quay.io/keycloak/keycloak:26.7.3 \
  start

Wait for the Admin Console at http://127.0.0.1:8083/admin/, then create realm pgpersist the same way you created persistlab above.

IMPORTANT
Do not remove PostgreSQL or volume keycloak-pg-data during this test. We want to prove that only the Keycloak application container can be replaced.

Remove only the Keycloak container — leave PostgreSQL and volume keycloak-pg-data running:

bash
podman rm -f kc-postgres-app

Run the same Keycloak podman run command again:

bash
podman run -d \
  --name kc-postgres-app \
  --network kc-persist-net \
  -p 127.0.0.1:8083:8080 \
  -e KC_HOSTNAME='http://localhost:8083' \
  -e KC_HTTP_ENABLED=true \
  -e KC_DB=postgres \
  -e KC_DB_URL='jdbc:postgresql://kc-postgres:5432/keycloak' \
  -e KC_DB_USERNAME=keycloak \
  -e KC_DB_PASSWORD='KcDb!2026' \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD='ChangeMe!2026' \
  quay.io/keycloak/keycloak:26.7.3 \
  start

When the Admin Console is ready, open Manage realms — pgpersist is still listed because PostgreSQL kept the rows.

In this Keycloak 26.7.3 test, /opt/keycloak/data inside the Keycloak container held only runtime files such as transaction-logs, not the realm rows themselves.


What is stored under /opt/keycloak/data?

Keycloak's official definition: /opt/keycloak/data is the server runtime-data directory. Keycloak may place local runtime information there; transaction logs are one documented example.

What I observed in Keycloak 26.7.3: a fresh start-dev container additionally stored the local development database under data/h2. With start and PostgreSQL connected, the same path contained only transaction-logs — no h2 directory because realm data was in PostgreSQL.

In the start-dev test, the directory contained:

  • h2/ — dev-file database files (keycloakdb.mv.db) used by development mode
  • transaction-logs/ — files that support transaction recovery and internal transaction processing; this is not the database containing your Keycloak realms and users

List the directory from the no-volume dev container:

bash
podman exec kc-dev-no-volume ls -la /opt/keycloak/data

Sample output:

output
total 0
drwxrwxr-x. 1 keycloak root 40 Sep  6 15:39 .
drwxr-xr-x. 1 keycloak root 29 Sep  6 15:39 ..
drwxr-xr-x. 2 keycloak root 57 Sep  6 15:39 h2
drwxr-xr-x. 3 keycloak root 35 Sep  6 15:39 transaction-logs

With start and PostgreSQL connected, the same path in this test contained only transaction-logs:

bash
podman exec kc-postgres-app ls -la /opt/keycloak/data

Sample output:

output
total 0
drwxrwxr-x. 1 keycloak root 30 Sep  6 15:44 .
drwxr-xr-x. 1 keycloak root 29 Sep  6 15:44 ..
drwxr-xr-x. 3 keycloak root 35 Sep  6 15:44 transaction-logs

Do not treat this lab listing as a guarantee that no other runtime paths will ever appear under data/. Some Keycloak features and providers can use filesystem data under that directory.

NOTE
The external database stores persistent realm/user/client data, but /opt/keycloak/data can still contain operational state required by specific features. For example, Keycloak documents stable storage for data/transaction-logs when XA transaction recovery is enabled. Persisting that operational state is separate from persisting realm and user data.

Other Keycloak directories you may want to mount

These paths are not substitutes for database persistence. They matter only if you deliberately use the corresponding Keycloak feature.

Path What it contains Persistence required?
/opt/keycloak/data Runtime data; dev-file DB in development Usually for start-dev dev-file persistence; some features may require specific runtime data to persist
/opt/keycloak/data/import Realm JSON used for startup import with --import-realm Mount when importing realms at startup
/opt/keycloak/conf Server configuration Only if supplying configuration as files
/opt/keycloak/providers Custom provider JARs Preserve or redeploy custom providers
/opt/keycloak/themes Custom themes Preserve or redeploy custom themes

/opt/keycloak/data/import is different from persistent application data. Keycloak reads realm JSON files placed there when --import-realm is used. It is an import location, not the live database. If the realm already exists, startup import skips it.

None of these paths replace PostgreSQL for user storage when external database mode is enabled. Prefer KC_* environment variables or pipeline-managed configuration for server settings — see Keycloak keycloak.conf configuration.


Common Keycloak persistence problems

Symptom Likely cause Fix
Realms gone after podman rm on start-dev No volume on /opt/keycloak/data; dev-file data lived in the container layer Add a named volume or bind mount for dev, or move to external PostgreSQL
Mounted /opt/keycloak/data but dev realms still disappear Recreated with a different volume name, wrong mount target, or unusable bind mount Verify the same volume name is mounted at /opt/keycloak/data on the recreated container
Container starts but cannot write mounted directory Bind-mount permissions or SELinux labeling Check host-directory permissions and, on SELinux systems, appropriate labeling such as :Z
Expected PostgreSQL to persist realms but data disappears Database container or storage was recreated without persistent storage Persist PostgreSQL storage and verify KC_DB_* points Keycloak to that database
Assumed /opt/keycloak/data holds users with PostgreSQL Persistent realm/user/client rows are in PostgreSQL, not dev-file files Back up the database; treat Keycloak container as replaceable
Realm persists in Keycloak but you're unsure which DB is being used Database configuration may not be what you expect Check Keycloak startup configuration and verify the test realm appears in the configured external database
podman stop / podman start "lost" data Confused restart with podman rm Restart keeps the same container filesystem; recreate deletes unmounted data
PostgreSQL volume deleted "to reset Keycloak" Database files removed with the volume Recreate DB only on empty volume; separate Keycloak and PostgreSQL lifecycle

Summary

For start-dev, mount /opt/keycloak/data when you need development data to survive container recreation. With PostgreSQL-backed Keycloak, persist the PostgreSQL data directory instead when PostgreSQL runs in a container; the Keycloak container can be recreated and reconnect to the same database. Managed or external databases use their own storage and backup systems.

Mounting /opt/keycloak/data is not universally the Keycloak persistence solution. What you persist depends on whether you use local development storage or an external database.


References


Frequently Asked Questions

Should I mount /opt/keycloak or /opt/keycloak/data in production?

With a standard external-PostgreSQL setup, /opt/keycloak/data is not required for realm and user persistence. Persistent realm/user/client state belongs in PostgreSQL. Some Keycloak features can have separate filesystem persistence requirements, such as XA transaction recovery. Mount only paths you intentionally customize (themes/, providers/) or use environment variables and rebuilt images.

Does a volume on /opt/keycloak/data backup my PostgreSQL users?

No. When KC_DB=postgres is set, users and realms are rows in PostgreSQL. The Keycloak data directory may contain transaction logs but not the H2 user database. Persist the database instead.

Why do Docker tutorials always add -v keycloak_data:/opt/keycloak/data?

Those tutorials use start-dev with an embedded H2 database. The volume keeps tutorial realms across container recreation. External-database layouts make the Keycloak container disposable.

What happens if I delete only the PostgreSQL volume?

All realm and user data in that database is destroyed. Recreating PostgreSQL on an empty volume gives Keycloak a fresh schema on next startup. Keep database backups separate from Keycloak container lifecycle.

Where is Keycloak data stored in Docker?

With start-dev, realm and user data for development is stored under /opt/keycloak/data/h2 inside the container — mount /opt/keycloak/data to keep it across recreation. With external PostgreSQL, persistent realm/user/client data is stored in the database, not in the Keycloak container filesystem.

Does Keycloak lose users when the Docker container restarts?

No. docker restart, docker stop followed by docker start, and the Podman equivalents keep the same container filesystem. Users are lost when you remove the container without a volume on /opt/keycloak/data (in start-dev) or when you delete the database volume or storage backing external PostgreSQL.

Should I use a Docker named volume or bind mount for Keycloak?

Both work. A named volume is managed by Docker or Podman; a bind mount maps a host directory such as ./keycloak-data:/opt/keycloak/data. Use a named volume when you want the engine to manage storage; use a bind mount when you want a specific host directory. For start-dev, mount /opt/keycloak/data; for external PostgreSQL, persist the database storage instead.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)