Configure Keycloak with PostgreSQL

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 — 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
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

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-net shared by Keycloak and PostgreSQL containers
  • PostgreSQL 16 with database keycloak, user keycloak, and named volume keycloak-pg-data
  • Keycloak 26.7.3 on start with KC_DB=postgres and a JDBC URL that uses the PostgreSQL container name as hostname
  • Bootstrap admin admin / ChangeMe!2026 and HTTP on 127.0.0.1:8080 for local Admin Console access
  • Proof that realm golinuxcloud survives podman rm on 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:

bash
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:

bash
podman network create keycloak-pg-net

podman 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:

bash
podman rm -f keycloak-pg-lab keycloak-postgres 2>/dev/null || true
podman volume rm keycloak-pg-data 2>/dev/null || true

Start PostgreSQL on keycloak-pg-net with a stable container name that Keycloak will use as JDBC hostname:

bash
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:16

PostgreSQL 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:

bash
podman exec keycloak-postgres pg_isready -U keycloak -d keycloak
output
/var/run/postgresql:5432 - accepting connections

Confirm the database account can connect:

bash
podman exec keycloak-postgres psql -U keycloak -d keycloak -c '\conninfo'
output
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
bash
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 \
  start

The 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:

bash
podman logs -f keycloak-pg-lab

Press Ctrl+C when you see lines like these (trimmed):

output
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 schema means Liquibase created Keycloak tables in PostgreSQL on first connect.
  • jdbc-postgresql in installed features confirms the PostgreSQL driver is active.
  • Profile prod activated means you are on production start, not start-dev.

Confirm HTTP responds on the host:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
output
302

HTTP 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:

text
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:

bash
-e KC_DB_SCHEMA=keycloak

Or on the CLI:

bash
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:

bash
-e KC_DB_TLS_MODE=verify-server \
-e KC_DB_TLS_TRUST_STORE_FILE=/path/to/server-or-ca.pem

CLI 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:

bash
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:

bash
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}'
output
201

HTTP 201 means the realm was stored in PostgreSQL. List realms to confirm:

bash
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)])'
output
['golinuxcloud', 'master']
IMPORTANT
The next step deletes only the Keycloak container. PostgreSQL and volume 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:

bash
podman rm -f keycloak-pg-lab

Run 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:

bash
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:

bash
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)])'
output
['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:

bash
podman exec keycloak-postgres psql -U keycloak -d keycloak -c '\dt' | head -20
output
                     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


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.

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)