| 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 — JDBC URL and KC_DB_* variables are the same across runtimes and the ZIP distribution |
| Privilege | Normal user for rootless Podman; root when your site policy requires rootful containers |
| Lab environment | Podman and the official Keycloak image from Install Keycloak with Podman; production start basics from Keycloak start vs start-dev |
| Scope | Keycloak PostgreSQL options (KC_DB, JDBC URL), shared container network, PostgreSQL readiness, production start against PostgreSQL, schema and TLS notes, and a persistence test after Keycloak container replacement — not backup, migration, Docker Compose, or HA clustering. |
| Related guides | Keycloak environment variables Keycloak container volumes and persistence Build an optimized Keycloak container |
In this article we will configure Keycloak to use PostgreSQL as its external database on Keycloak 26.7.3.
For production deployments, Keycloak should use a supported external database instead of the built-in dev-file database. In this guide, you will configure PostgreSQL as that database and start Keycloak in production mode. An optimized custom Keycloak image can be built separately when faster startup is required. The tested examples use Podman; replace podman with the equivalent Docker command when using Docker.
What you will configure
Keycloak stores persistent identity configuration — including realms, users, credentials, clients, roles, and authentication settings — in its relational database. The default dev-file database is for development only. This guide puts that data in PostgreSQL.
By the end, you will have:
- A Podman network
keycloak-pg-netshared by Keycloak and PostgreSQL containers - PostgreSQL
16with databasekeycloak, userkeycloak, and named volumekeycloak-pg-data - Keycloak
26.7.3onstartwithKC_DB=postgresand a JDBC URL that uses the PostgreSQL container name as hostname - Bootstrap admin
admin/ChangeMe!2026and HTTP on127.0.0.1:8080for local Admin Console access - Proof that realm
golinuxcloudsurvivespodman rmon the Keycloak container when PostgreSQL and its volume keep running
What persists where:
- Remove or recreate the Keycloak container with the same JDBC settings — realm data survives in PostgreSQL
- Remove or recreate the PostgreSQL container with the same named volume — database data survives
- Delete volume
keycloak-pg-data— database data is lost
This article (left to right):
┌─────────────┐ JDBC ┌──────────────────┐ volume
│ Keycloak │ ──────────────► │ keycloak-postgres│ ───► keycloak-pg-data
│ start + │ keycloak-pg-net│ PostgreSQL 16 │
│ KC_DB_* │ │ db: keycloak │
└──────┬──────┘ └──────────────────┘
│
└── 127.0.0.1:8080 (Admin Console on host)Keycloak PostgreSQL configuration options
On a ZIP or package install, start Keycloak with PostgreSQL settings:
bin/kc.sh start \
--db=postgres \
--db-url=jdbc:postgresql://db.example.com:5432/keycloak \
--db-username=keycloak \
--db-password='password'The same options become KC_DB, KC_DB_URL, KC_DB_USERNAME, and KC_DB_PASSWORD in containers.
A JDBC URL is the connection address Keycloak uses to find the PostgreSQL server, port, and database.
Option A — full JDBC URL (used in this lab)
| Variable | Example value |
|---|---|
KC_DB |
postgres |
KC_DB_URL |
jdbc:postgresql://keycloak-postgres:5432/keycloak |
KC_DB_USERNAME |
keycloak |
KC_DB_PASSWORD |
KcDb!2026 |
Option B — host components instead of one URL
| Variable | Example value |
|---|---|
KC_DB |
postgres |
KC_DB_URL_HOST |
keycloak-postgres |
KC_DB_URL_PORT |
5432 |
KC_DB_URL_DATABASE |
keycloak |
KC_DB_USERNAME |
keycloak |
KC_DB_PASSWORD |
KcDb!2026 |
Both options produce the same JDBC connection. When KC_DB_URL is set, Keycloak ignores the URL component options such as KC_DB_URL_HOST and KC_DB_URL_DATABASE. Use the full URL when you copy a single string from documentation; use components when a chart or operator sets host and port separately.
Create the PostgreSQL database
Both containers must resolve each other by name on the same user-defined network. Create the network once:
podman network create keycloak-pg-netpodman network create prints the network ID when the network is new. If keycloak-pg-net already exists, reuse it after confirming it is the intended network, or choose a different name for an isolated lab.
Remove any leftover lab containers and volume so this walkthrough starts clean:
podman rm -f keycloak-pg-lab keycloak-postgres 2>/dev/null || true
podman volume rm keycloak-pg-data 2>/dev/null || trueStart PostgreSQL on keycloak-pg-net with a stable container name that Keycloak will use as JDBC hostname:
podman run -d \
--name keycloak-postgres \
--network keycloak-pg-net \
--network-alias keycloak-postgres \
-e POSTGRES_DB=keycloak \
-e POSTGRES_USER=keycloak \
-e POSTGRES_PASSWORD='KcDb!2026' \
-v keycloak-pg-data:/var/lib/postgresql/data:Z \
docker.io/library/postgres:16PostgreSQL does not need a host port in this setup. Keycloak connects to it directly over keycloak-pg-net using the container name keycloak-postgres and PostgreSQL's internal port 5432.
| Variable | Purpose |
|---|---|
--name keycloak-postgres |
DNS name other containers use in the JDBC URL |
POSTGRES_DB |
Database Keycloak will connect to |
POSTGRES_USER / POSTGRES_PASSWORD |
Database owner created on first init |
-v keycloak-pg-data:… |
Named volume so database files survive PostgreSQL container recreation |
In this lab, POSTGRES_USER=keycloak creates keycloak as a PostgreSQL superuser. That keeps the setup simple. For a real deployment, create a separate non-superuser database account for Keycloak instead — for example with scripts under /docker-entrypoint-initdb.d/.
Wait until PostgreSQL accepts connections before you start Keycloak:
podman exec keycloak-postgres pg_isready -U keycloak -d keycloak/var/run/postgresql:5432 - accepting connectionsConfirm the database account can connect:
podman exec keycloak-postgres psql -U keycloak -d keycloak -c '\conninfo'You are connected to database "keycloak" as user "keycloak" via socket in "/var/run/postgresql" at port "5432".The accepting connections line means PostgreSQL is ready for Keycloak’s first start.
Start Keycloak with PostgreSQL
Add production HTTP settings for this local lab, then start Keycloak on the same network. The podman run command publishes 127.0.0.1:8080 and ends with production start:
| Variable | Purpose |
|---|---|
KC_HOSTNAME |
localhost — public URL the Admin Console expects |
KC_HTTP_ENABLED |
true — allow HTTP on port 8080 behind localhost only |
KC_BOOTSTRAP_ADMIN_USERNAME |
Creates the initial Keycloak administrator when the database is first initialized |
KC_BOOTSTRAP_ADMIN_PASSWORD |
Password for that initial administrator |
podman run -d \
--name keycloak-pg-lab \
--network keycloak-pg-net \
-p 127.0.0.1:8080:8080 \
-e KC_HOSTNAME=localhost \
-e KC_HTTP_ENABLED=true \
-e KC_DB=postgres \
-e KC_DB_URL='jdbc:postgresql://keycloak-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 \
startThe first startup can take longer because Keycloak creates and initializes its database schema. podman ps showing Up does not mean Keycloak is ready yet, so watch the logs:
podman logs -f keycloak-pg-labPress Ctrl+C when you see lines like these (trimmed):
Initializing database schema. Using changelog META-INF/jpa-changelog-master.xml
...
Installed features: [agroal, cdi, hibernate-orm, jdbc-postgresql, keycloak, ...]
...
Keycloak 26.7.3 on JVM (powered by Quarkus 3.33.3.1) started in 32.771s. Listening on: http://0.0.0.0:8080
Profile prod activated.These messages confirm three things:
Initializing database schemameans Liquibase created Keycloak tables in PostgreSQL on first connect.jdbc-postgresqlin installed features confirms the PostgreSQL driver is active.Profile prod activatedmeans you are on productionstart, notstart-dev.
Confirm HTTP responds on the host:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/302HTTP 302 is expected before you open http://127.0.0.1:8080/admin/ in a browser.
Why localhost in the JDBC URL fails
Inside the Keycloak container, localhost means the Keycloak container itself — not the host machine and not the PostgreSQL container.
| JDBC hostname | Resolves to | Result in this lab |
|---|---|---|
localhost |
Keycloak container loopback | Connection refused — no PostgreSQL listener there |
keycloak-postgres |
PostgreSQL container on keycloak-pg-net |
Works — Podman DNS on the shared network |
| Host IP from container | The Linux host | Works only when PostgreSQL listens on the host and firewall rules allow it — not this article’s layout |
A wrong hostname produces errors like this in Keycloak logs:
Connection to localhost:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.When PostgreSQL runs in another container on the same network, use its container or service name instead of localhost. For an external PostgreSQL server, use that server's reachable DNS name or address.
Use a custom PostgreSQL schema
By default Keycloak uses the database’s default schema (public on PostgreSQL). Override it with:
-e KC_DB_SCHEMA=keycloakOr on the CLI:
bin/kc.sh start --db=postgres --db-schema=keycloak ...Keycloak does not create a custom schema named in KC_DB_SCHEMA; create that schema first and make sure the Keycloak database user has permission to use it. This lab keeps PostgreSQL's default public schema.
Connect to PostgreSQL over TLS
If PostgreSQL runs on another server or a managed database service, the connection may need TLS. With verify-server, Keycloak encrypts the connection and also checks that it is talking to the expected PostgreSQL server:
-e KC_DB_TLS_MODE=verify-server \
-e KC_DB_TLS_TRUST_STORE_FILE=/path/to/server-or-ca.pemCLI form: --db-tls-mode=verify-server and --db-tls-trust-store-file. Mount the trust material into the Keycloak container when you use file paths. This lab uses plain TCP on the shared Podman network and does not enable database TLS.
Prove persistence across Keycloak container replacement
Create a test realm through the Admin REST API so you can verify PostgreSQL holds the data:
TOKEN=$(curl -s -X POST 'http://127.0.0.1:8080/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=admin' \
-d 'password=ChangeMe!2026' \
-d 'grant_type=password' \
-d 'client_id=admin-cli' | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')Request a token for the bootstrap admin, then create realm golinuxcloud:
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8080/admin/realms' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"realm":"golinuxcloud","enabled":true}'201HTTP 201 means the realm was stored in PostgreSQL. List realms to confirm:
curl -s 'http://127.0.0.1:8080/admin/realms' -H "Authorization: Bearer ${TOKEN}" | python3 -c 'import sys,json; print([r["realm"] for r in json.load(sys.stdin)])'['golinuxcloud', 'master']keycloak-pg-data keep running — that is what makes persistence work.
Remove the Keycloak container and start a new one with the same JDBC settings:
podman rm -f keycloak-pg-labRun the same podman run command from the start section again (same KC_DB_* values and network). After Keycloak listens on port 8080, fetch a new token and list realms:
TOKEN=$(curl -s -X POST 'http://127.0.0.1:8080/realms/master/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=admin' \
-d 'password=ChangeMe!2026' \
-d 'grant_type=password' \
-d 'client_id=admin-cli' | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')Ask the Admin API whether golinuxcloud survived in PostgreSQL:
curl -s 'http://127.0.0.1:8080/admin/realms' -H "Authorization: Bearer ${TOKEN}" | python3 -c 'import sys,json; print([r["realm"] for r in json.load(sys.stdin)])'['golinuxcloud', 'master']golinuxcloud is still present because the realm was stored in PostgreSQL, not inside the Keycloak container.
Inspect Keycloak tables in PostgreSQL
Keycloak owns its schema — you do not create tables manually. After first startup, PostgreSQL holds Keycloak-managed relations such as realm, client, and user_entity:
podman exec keycloak-postgres psql -U keycloak -d keycloak -c '\dt' | head -20 List of relations
Schema | Name | Type | Owner
--------+-------------------------------+-------+----------
public | admin_event_entity | table | keycloak
public | client | table | keycloak
public | client_scope | table | keycloak
public | credential | table | keycloak
public | realm | table | keycloak
public | user_entity | table | keycloak
...That list confirms connectivity worked and Liquibase initialized the database. On version upgrades, Keycloak runs further Liquibase migrations during startup. Plan maintenance windows for major upgrades — this article does not cover backup or rollback procedures.
What needs persistent storage
| Component | Replaceable? | What to persist |
|---|---|---|
| Keycloak container image | Yes — roll out new tags often | Nothing inside the container except build cache when you use start --optimized |
| PostgreSQL container | Yes — recreate with the same volume | Named volume or external disk mounted at the PostgreSQL data directory |
| Realm and user data | Must survive | PostgreSQL data directory (keycloak-pg-data in this lab) |
| TLS certificates, themes, providers | Depends on layout | Host mounts, secrets, or custom image — see the container persistence sibling under Related guides |
Keycloak containers can be replaced, but the PostgreSQL data volume must be preserved and backed up.
Password handling in configuration
Passing KC_DB_PASSWORD on the podman run command line is convenient for labs but exposes the value in shell history and process listings. For a real deployment, avoid putting the database password directly in the command:
- Store the environment file outside the project tree with restrictive permissions such as
chmod 600, owned by the account that runs Podman or Docker, then pass--env-file /path/keycloak-db.env - Use your orchestrator’s secret store (Kubernetes Secret, OpenShift Secret, and similar)
- Use Keycloak's configuration keystore if you do not want to store the password in a plain environment variable
Passwords that contain $ need special care. Keycloak documents KCRAW_DB_PASSWORD so configuration expansion preserves literal $ and ${...} characters — still quote the value correctly in the shell, and see the environment-variables sibling under Related guides for details.
Common problems
| Symptom | Likely cause | Fix |
|---|---|---|
Connection to localhost:5432 refused |
JDBC URL uses localhost inside the Keycloak container |
Use PostgreSQL container name or service DNS on the shared network |
Connection refused to keycloak-postgres |
Keycloak not on keycloak-pg-net, or PostgreSQL not started |
Attach both containers to the same network; wait for pg_isready |
password authentication failed |
KC_DB_USERNAME / KC_DB_PASSWORD mismatch |
Align credentials with POSTGRES_USER / POSTGRES_PASSWORD; recreate DB only on empty volume |
database "keycloak" does not exist |
Wrong POSTGRES_DB or empty PostgreSQL volume |
Create database or fix KC_DB_URL database segment |
| Keycloak starts before PostgreSQL | Race on first boot | Wait for pg_isready; add a retry policy in production orchestration |
FATAL: no pg_hba.conf entry |
PostgreSQL rejects container subnet | Default postgres:16 image allows container network; check custom pg_hba.conf |
| Realm data missing after Keycloak recreate | JDBC URL pointed at a different database or fresh PostgreSQL volume | Keep keycloak-pg-data volume; verify KC_DB_URL unchanged |
| Realm data missing after PostgreSQL recreate | Named volume deleted or not remounted | Recreate PostgreSQL with -v keycloak-pg-data:…; deleting only the container keeps the volume |
| Bootstrap admin login fails on second container | Database already initialized with different admin | Bootstrap user is created once per database; use existing admin or reset DB |
$ in password breaks startup |
Keycloak configuration expansion interprets $ / ${...} in the password |
Use KCRAW_DB_PASSWORD and quote the value correctly when setting it through the shell |
| Schema migration errors after upgrade | Skipped versions or manual table edits | Restore from backup; follow Keycloak upgrade notes for the target version |
Key material not provided on start |
Production mode without HTTP or TLS | Set KC_HTTP_ENABLED=true for lab HTTP or configure HTTPS |
Summary
You pointed Keycloak at PostgreSQL with KC_DB=postgres and a JDBC URL, created a shared container network, and started PostgreSQL 16 with database keycloak on named volume keycloak-pg-data. Keycloak 26.7.3 used production start with the PostgreSQL container name — not localhost — as the JDBC hostname.
First startup initialized the Keycloak schema in PostgreSQL. Realm golinuxcloud remained after you deleted and recreated the Keycloak container. Removing the PostgreSQL container while keeping keycloak-pg-data preserves the database; deleting that volume does not.
Next steps: move passwords off the command line, add TLS or a reverse proxy for the application front door, and build an optimized image when you want faster repeatable startups. Backup and migration procedures belong in their own articles.
References
- Configuring the database — supported vendors and versions, JDBC URL, schema, and database TLS
- Configuring Keycloak —
KC_*environment variables and configuration sources - Running Keycloak in a container — official image and
startcommand - PostgreSQL 16 documentation — database and user management
- Docker Official Image: postgres —
POSTGRES_*initialization behavior
Frequently Asked Questions
Can I use start-dev with PostgreSQL?
Yes. Set the same KC_DB_* variables and use start-dev as the command. You still get the development profile — relaxed hostname and HTTP defaults — while realm data lives in PostgreSQL. Production deployments use start with hostname, TLS or proxy, and external PostgreSQL together.
Does Keycloak create the PostgreSQL schema automatically?
Yes. On first connect Keycloak runs Liquibase migrations and creates its tables. You only create the empty database and a database user in PostgreSQL. Use KC_DB_SCHEMA only when you need a non-default schema that already exists.
Is POSTGRES_USER=keycloak a least-privilege account?
No. In the official PostgreSQL container image, POSTGRES_USER creates a PostgreSQL superuser. That keeps a compact lab simple. For a real deployment, initialize as postgres and create a non-superuser Keycloak role with access limited to the Keycloak database.
Will realm data survive podman stop on PostgreSQL?
Yes, as long as the PostgreSQL data volume remains. podman stop / podman start on either container preserves data. Deleting the PostgreSQL container without removing the named volume keeps the database files. Deleting keycloak-pg-data destroys them.
What PostgreSQL versions does Keycloak 26.7 support?
Keycloak’s current supported database list includes PostgreSQL 14.x through 18.x, with PostgreSQL 18 listed as the tested version. This lab used PostgreSQL 16, which is supported. Recheck the official database page when you move to a newer Keycloak release.

