| Tested on | Red Hat Enterprise Linux 10.2 (Coughlan) |
|---|---|
| Package | podman-5.8.2-5.el10_2.x86_64quay.io/keycloak/keycloak:26.7.3docker.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 |
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-devand 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:
quay.io/keycloak/keycloak:26.7.3 start-devand did not configure KC_DB, this article's /opt/keycloak/data section applies to you.
If you configured variables such as:
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:
podman rm -f kc-dev-no-volume 2>/dev/null || trueStart start-dev on port 8081 with no mount on /opt/keycloak/data:
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-devWait 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:
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 deletedMount 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:
-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:
-v keycloak-persist-dev-data:/opt/keycloak/data:ZWe 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:
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-devAfter the container starts, confirm that Podman created the volume:
podman volume ls | grep keycloak-persist-dev-dataSample output:
local keycloak-persist-dev-dataWait for the Admin Console at http://127.0.0.1:8082/admin/, then create a test realm such as volrealm.
Remove the container:
podman rm -f kc-dev-volumepodman 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:
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-devSame 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:
-v ./keycloak-data:/opt/keycloak/data:ZHere, ./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:
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 volumeIf 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.
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:
podman network create kc-persist-net 2>/dev/null || trueStart 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:
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:16PostgreSQL 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:
jdbc:postgresql://kc-postgres:5432/keycloak
└────┬────┘ └┬─┘ └──┬───┘
DB container port databasekc-postgres— PostgreSQL container hostname on the network5432— PostgreSQL port inside the container networkkeycloak— database created byPOSTGRES_DB=keycloak
Start Keycloak on start with JDBC pointed at that database:
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 \
startWait for the Admin Console at http://127.0.0.1:8083/admin/, then create realm pgpersist the same way you created persistlab above.
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:
podman rm -f kc-postgres-appRun the same Keycloak podman run command again:
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 \
startWhen 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 modetransaction-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:
podman exec kc-dev-no-volume ls -la /opt/keycloak/dataSample 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-logsWith start and PostgreSQL connected, the same path in this test contained only transaction-logs:
podman exec kc-postgres-app ls -la /opt/keycloak/dataSample 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-logsDo 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.
/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
- Keycloak configuring the database: supported vendors and JDBC configuration
- Keycloak containers guide: official image layout and startup commands
- Keycloak directory structure:
data,conf,providers, andthemespaths
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.

