Keycloak Docker Compose with PostgreSQL

Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package docker-ce-3:29.8.0-1.el10.x86_64 (Docker Engine 29.8.0)
docker compose v5.5.1
quay.io/keycloak/keycloak:26.7.3
postgres:16
Applies to Any Linux host with Docker Engine and Docker Compose installed
Privilege Normal user when your site allows it; root or sudo when Docker requires elevated access
Scope A two-service compose.yaml with Keycloak start, PostgreSQL 16, bootstrap admin login, volume persistence, stack verification, confirming Keycloak data in PostgreSQL, and removing the Compose project — not single-container docker run, Podman Quadlet, or production TLS
Short on time?Beta Get the gist of this 15-minute read in seconds, then ask follow-up questions

Docker Compose gives you a reproducible Keycloak stack with PostgreSQL in one project directory. You define both services in compose.yaml, start them together, and persist PostgreSQL's database files in a named Docker volume instead of using Keycloak's embedded development database.

This guide runs Keycloak 26.7.3 on 127.0.0.1:8080 with PostgreSQL 16, uses current KC_BOOTSTRAP_ADMIN_* variables to create a temporary bootstrap administrator, and verifies that Keycloak stores the master realm in PostgreSQL.

If you only need the finished files, jump to Complete Keycloak + PostgreSQL Compose configuration.


How the Keycloak Compose stack works

Two containers start from one Compose project:

  • PostgreSQL 16 — stores Keycloak realm and user data in a named Docker volume
  • Keycloak 26.7.3 — connects over the Compose network with the start command and PostgreSQL settings
  ┌──────────────┐   JDBC (postgres:5432)   ┌─────────────────┐
  │ Keycloak     │ ───────────────────────► │ PostgreSQL 16   │
  │ start        │                          │ volume: pgdata  │
  └──────┬───────┘                          └─────────────────┘
         │
         └── 127.0.0.1:8080 (Admin Console on host)

Keycloak resolves the database hostname postgres through Docker Compose service DNS on the default project network. That hostname is the PostgreSQL service name, not localhost on your Linux host.


Requirements

Install Docker Engine and the Compose plugin before you create the project. On Debian and Ubuntu, see install Docker on Debian; on RHEL 8, 9, or 10, follow Install Docker on RHEL and verify docker compose version before continuing.

Confirm Docker Engine responds:

bash
docker version

Sample output (client and server sections trimmed):

output
Client: Docker Engine - Community
 Version:           29.8.0

Server: Docker Engine - Community
 Engine:
  Version:          29.8.0

Confirm the Compose plugin is available:

bash
docker compose version

Sample output:

output
Docker Compose version v5.5.1

Before you start the stack, check memory and port availability:

  • For this walkthrough, use a host with at least 4 GiB of available memory. The Keycloak container is capped at 2 GiB, while PostgreSQL, Docker, and the host OS need additional memory
  • Port 8080 on the host must be free — check with ss -tlnp | grep :8080

If you only need a single disposable Keycloak container without PostgreSQL, use Install Keycloak with Docker instead.


Create the Keycloak Compose project

Create a project directory and move into it:

bash
mkdir -p ~/keycloak-compose
cd ~/keycloak-compose

Both commands succeed silently when the directory already exists.


Choose how to configure Keycloak with Docker Compose

Compose can supply Keycloak settings through environment:, env_file:, or a mounted keycloak.conf. You can combine them when different settings come from different sources.

Keycloak reads server options from environment variables with the KC_ prefix — for example, health-enabled becomes KC_HEALTH_ENABLED.

Method Example Best suited for
environment: KC_HTTP_ENABLED: "true" under the keycloak service Settings in compose.yaml, optionally using ${VAR} from the project .env
env_file: env_file: - keycloak.env Several variables loaded from a file instead of a long environment: block
Mounted keycloak.conf ./conf/keycloak.conf:/opt/keycloak/conf/keycloak.conf:ro File-based settings; edit the host file and restart Keycloak

Compose has two different concepts that sound similar:

  • Project .env — supplies values Compose uses to replace ${VAR} placeholders in compose.yaml
  • env_file: — loads environment variables directly into a container

This guide uses the first approach: .env stores the values, and environment: maps what Keycloak and PostgreSQL receive. Changing .env alone does not update a running container unless those variables are referenced under environment:.

For build-time vs runtime options, precedence, and troubleshooting, see Keycloak keycloak.conf configuration.


Store project values in .env

Compose automatically reads a file named .env from the project directory and uses its values when it encounters placeholders such as ${POSTGRES_PASSWORD} in compose.yaml.

For example, POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} under the postgres service means "take POSTGRES_PASSWORD from the Compose .env file and pass it to the PostgreSQL container."

Store passwords and the pinned Keycloak version in .env so compose.yaml stays readable:

bash
cat > .env << 'EOF'
KEYCLOAK_VERSION=26.7.3
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=KcDb!2026
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe!2026
EOF

These variables are read by Compose when it expands ${VAR} placeholders in compose.yaml. They reach Keycloak only because the next section maps them under keycloak.environment. Add .env to .gitignore before using real credentials. For this lab the passwords are deliberately simple example values — .env is not secret storage, so do not commit it to version control.


Create compose.yaml

Create compose.yaml with PostgreSQL, a database health check, and Keycloak configured to connect to it:

bash
cat > compose.yaml << 'EOF'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

  keycloak:
    image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
    depends_on:
      postgres:
        condition: service_healthy
    command: start
    environment:
      KC_HOSTNAME: http://127.0.0.1:8080
      KC_HTTP_ENABLED: "true"
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
      KC_DB_USERNAME: ${POSTGRES_USER}
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
    ports:
      - "127.0.0.1:8080:8080"
    mem_limit: 2g

volumes:
  pgdata:
EOF

You now have a two-service project file in the current directory.

This example uses Keycloak's start command, so Keycloak runs with the production profile. This is still a local learning setup: HTTP is explicitly enabled, KC_HOSTNAME matches the loopback URL you open in the browser, and the Admin Console is published only on 127.0.0.1. A real production deployment still needs a public hostname, TLS or reverse-proxy configuration, secrets management, and other production controls.

KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD create the temporary bootstrap administrator during initial startup. Older tutorials may show the legacy KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD names; use the KC_BOOTSTRAP_ADMIN_* variables with current Keycloak releases.


Understand the Keycloak Compose file

Part Role in this stack
postgres service Runs PostgreSQL 16 and creates database keycloak with user keycloak on first startup
pgdata volume Docker-managed named volume; PostgreSQL writes database files to /var/lib/postgresql/data inside the container while Docker keeps that data separately from the container itself
healthcheck + depends_on Waits for pg_isready to confirm PostgreSQL is accepting connections before Compose starts Keycloak
quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} Official Keycloak image pinned to 26.7.3 through .env
command: start Runs Keycloak with the production profile rather than start-dev
KC_DB=postgres Selects PostgreSQL as Keycloak's database vendor. The stock image used here handles this setting during startup; optimized production images normally set the database vendor while building the image. See Keycloak start vs start-dev for the distinction
KC_HOSTNAME Public URL Keycloak uses for redirects and issuer URLs; set to http://127.0.0.1:8080 so it matches how you open the Admin Console on the host
KC_DB_URL JDBC URL; hostname postgres is the Compose service name on the project network
KC_DB_USERNAME / KC_DB_PASSWORD Application credentials that match POSTGRES_USER and POSTGRES_PASSWORD
KC_BOOTSTRAP_ADMIN_* Creates a temporary bootstrap administrator when the master realm is initialized for the first time
"127.0.0.1:8080:8080" Publishes the Admin Console on host loopback only
mem_limit: 2g Caps Keycloak container memory; Keycloak uses this limit when sizing its JVM

The POSTGRES_* variables configure the PostgreSQL container itself, while Keycloak's KC_DB_* variables tell Keycloak how to connect to that database. The username and password therefore need to agree on both sides.

PostgreSQL uses POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD when initializing an empty data directory. Once pgdata contains an initialized database, changing those values does not recreate the database or automatically change the existing role password.

The pgdata named volume is why PostgreSQL data survives when Compose recreates the PostgreSQL container. PostgreSQL's container can be running before the database is ready for connections; the health check prevents Keycloak from starting during that window.

Inside the Keycloak container, localhost points at Keycloak itself — not PostgreSQL. The JDBC hostname must be postgres, the service name Compose publishes on the internal network. See Configure Keycloak with PostgreSQL for the same hostname rule on Podman networks.


Start Keycloak and PostgreSQL

Start both services in the background from the project directory:

bash
docker compose up -d

Compose pulls missing images, creates the pgdata volume if needed, waits for PostgreSQL to become healthy, then starts Keycloak. Startup time depends on the host. On the test VM used here, Keycloak initialized the new PostgreSQL database and became ready in about 51 seconds.

Sample output (trimmed):

output
 Container keycloak-compose-postgres-1 Healthy
 Container keycloak-compose-keycloak-1 Started

Compose names containers from the project directory — for ~/keycloak-compose, expect names such as keycloak-compose-postgres-1 and keycloak-compose-keycloak-1.


Verify both containers

List running services:

bash
docker compose ps

Sample output:

output
NAME                          IMAGE                              COMMAND                  SERVICE    STATUS                    PORTS
keycloak-compose-keycloak-1   quay.io/keycloak/keycloak:26.7.3   "/opt/keycloak/bin/k…"   keycloak   Up About a minute         127.0.0.1:8080->8080/tcp
keycloak-compose-postgres-1   postgres:16                        "docker-entrypoint.s…"   postgres   Up 2 minutes (healthy)    5432/tcp

Up on Keycloak confirms that Docker still has the container process running; it does not prove that Keycloak has finished starting. Check the Keycloak logs next. healthy on PostgreSQL means the health check passed.

PostgreSQL shows 5432/tcp without a host mapping because this lab does not publish the database port to the Linux host. Keycloak can still reach PostgreSQL on port 5432 through the internal Compose network.

Confirm PostgreSQL accepts connections. These values match POSTGRES_USER and POSTGRES_DB from .env:

bash
docker compose exec postgres pg_isready -U keycloak -d keycloak

Sample output:

output
/var/run/postgresql:5432 - accepting connections

accepting connections confirms PostgreSQL is ready for Keycloak's JDBC URL.

Read Keycloak startup logs:

bash
docker compose logs keycloak

Sample output (trimmed):

output
Initializing database schema. Using changelog META-INF/jpa-changelog-master.xml
KC-SERVICES0050: Initializing master realm
KC-SERVICES0077: Created temporary admin user with username admin
Keycloak 26.7.3 on JVM (powered by Quarkus 3.33.3.1) started in 50.553s. Listening on: http://0.0.0.0:8080
Profile prod activated.
Installed features: [agroal, cdi, hibernate-orm, jdbc-postgresql, keycloak, ...]

Wait for the Listening on: line before you open the Admin Console. The schema initialization messages indicate that Keycloak reached the configured database. The later PostgreSQL query provides direct confirmation that Keycloak's realm data was written there.

Check HTTP from the host:

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

Sample output:

output
302

HTTP 302 is a redirect response. Here it is expected: /admin/ redirects your browser toward Keycloak's login flow, so the response confirms that Keycloak is answering HTTP requests.


Open the Keycloak Admin Console

On the same host where port 8080 is published, open:

text
http://127.0.0.1:8080/admin/

When Docker runs on a remote Linux host, forward port 8080 over SSH before you open the URL in a local browser.

Sign in with username admin and password ChangeMe!2026 from your .env file. Keycloak created this account as a temporary bootstrap administrator during first startup.

Keycloak 26.7 Admin Console login page at http://127.0.0.1:8080/admin/ with username and password fields

The bootstrap account is temporary. For a longer-lived setup, use this temporary account to establish your normal administrative access, then follow the Keycloak bootstrap admin guide for the appropriate cleanup and recovery lifecycle. For a disposable learning lab, you can leave the bootstrap variables in place temporarily.


Verify Keycloak is actually using PostgreSQL

Keycloak logs show schema initialization. Confirm the realm data landed in PostgreSQL directly:

bash
docker compose exec postgres psql -U keycloak -d keycloak -c "SELECT name FROM realm;"

Sample output:

output
  name
--------
 master
(1 row)

Seeing the master realm directly in PostgreSQL confirms that this Keycloak instance initialized and is using the external database.


Stop and restart the stack

To pause both services without removing containers, run:

bash
docker compose stop

The command exits silently when both services stop.

Bring the existing containers back with:

bash
docker compose start

Then check docker compose ps and the Keycloak logs until PostgreSQL is healthy and Keycloak has finished starting. The pgdata volume keeps the existing database files.

docker compose down is different: it removes containers and the project network but keeps named volumes such as pgdata by default. Use down when you want to tear down the Compose project while retaining PostgreSQL data for a later docker compose up -d.


Remove the Compose stack

From the project directory, remove containers and the project network while keeping PostgreSQL data:

bash
cd ~/keycloak-compose && docker compose down

To remove containers, the network, and the named database volume:

bash
docker compose down -v

The second command permanently deletes the PostgreSQL data for this Compose project. Delete ~/keycloak-compose afterward if you no longer need the configuration files.


Change Keycloak configuration after installation

There is an important difference between environment-based configuration and a bind-mounted file after Compose has created the containers.

After changing environment: values, a file referenced by env_file:, or values substituted into the service configuration, run:

bash
docker compose up -d

Compose compares the desired service configuration with the existing container and recreates the affected service when necessary. The pgdata volume survives unless you remove it with docker compose down -v or docker volume rm.

Editing the project .env file alone does not change a running container unless those variables are referenced under environment: and Compose recreates the service on the next up.

A bind-mounted keycloak.conf behaves differently. Edit the host file and restart Keycloak:

bash
docker compose restart keycloak

Keycloak reads the mounted configuration when it starts.

POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD are initialization settings for an empty PostgreSQL data directory. Changing them in .env after pgdata already exists does not reinitialize the database. For an existing database, make the corresponding database, user, or password changes inside PostgreSQL and keep Keycloak's KC_DB_* settings in sync. For a disposable lab, docker compose down -v lets you initialize a fresh database from the new values.

For resolved values and precedence rules, use the show-config workflow in the Keycloak configuration guide.

Pin a new Keycloak version in .env, run docker compose pull keycloak, then docker compose up -d. Read the Keycloak release notes before you upgrade production data.


Common Keycloak Docker Compose problems

Symptom Likely cause Fix
Connection to localhost:5432 refused in Keycloak logs JDBC URL uses localhost instead of the Compose service name Set KC_DB_URL to jdbc:postgresql://postgres:5432/keycloak — inside Keycloak, localhost is the Keycloak container
password authentication failed for user Keycloak password does not match the existing PostgreSQL role Correct the role and password on both sides. For a disposable lab only, docker compose down -v wipes the database so it can be initialized again from the current .env values
Keycloak exits before Listening on: while PostgreSQL is still starting Race before PostgreSQL accepts connections Keep the PostgreSQL healthcheck and depends_on: condition: service_healthy shown above
Bootstrap login fails on an existing stack The master realm was already initialized Use an existing administrator or follow Keycloak bootstrap recovery — startup bootstrap variables apply only on first database initialization
Realm data missing after docker compose down -v Used -v or deleted the pgdata volume Expected when the volume is removed; omit -v to keep PostgreSQL data
Tutorial uses KEYCLOAK_ADMIN Outdated Compose examples use legacy bootstrap variable names Replace with KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD
Added KC_* to .env but Keycloak ignores it Project .env only expands ${VAR} in compose.yaml Also add the variable under keycloak.environment or use env_file:
bind: address already in use on port 8080 Another process or container uses 8080 ss -tlnp | grep :8080; stop the conflicting service or change the host port in ports

Complete Keycloak + PostgreSQL Compose configuration

If you followed the walkthrough above, this is the same configuration collected in one place for reference.

yaml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

  keycloak:
    image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
    depends_on:
      postgres:
        condition: service_healthy
    command: start
    environment:
      KC_HOSTNAME: http://127.0.0.1:8080
      KC_HTTP_ENABLED: "true"
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
      KC_DB_USERNAME: ${POSTGRES_USER}
      KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
    ports:
      - "127.0.0.1:8080:8080"
    mem_limit: 2g

volumes:
  pgdata:

.env for the same project:

text
KEYCLOAK_VERSION=26.7.3
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=KcDb!2026
# Bootstrap credentials for the first startup only
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe!2026

After you establish normal administrative access, follow the bootstrap admin guide to remove the temporary bootstrap account and delete the two KC_BOOTSTRAP_ADMIN_* entries from .env and compose.yaml.


Summary

Keycloak now runs with PostgreSQL as a two-service Docker Compose project. Compose waits for PostgreSQL to become healthy before starting Keycloak, and the pgdata named volume keeps realm data when containers are recreated. Inside the Compose network, Keycloak reaches PostgreSQL using the service hostname postgres, not localhost.

This example uses Keycloak's start command so Keycloak runs with the production profile, but it deliberately exposes HTTP only on 127.0.0.1 for a local lab. A real production deployment still needs an appropriate public hostname, TLS or reverse-proxy configuration, and production secrets management.

To remove the lab, run docker compose down from ~/keycloak-compose, then docker compose down -v when you want to delete keycloak-compose_pgdata and all realm data. Delete the project directory last if you no longer need the files.


References


Frequently Asked Questions

Can I use start-dev in this Compose file?

Yes. start-dev can connect to PostgreSQL, but it still runs Keycloak with development-mode defaults. This guide deliberately uses start so Keycloak runs with the production profile while backed by PostgreSQL. The surrounding HTTP and hostname configuration here is still intended for a local lab, not a production deployment.

Why does the JDBC URL use postgres instead of localhost?

Inside the Keycloak container, localhost refers to Keycloak itself. Docker Compose publishes the PostgreSQL service on the project network under its service name postgres, which is the hostname other containers must use in KC_DB_URL.

Do KC_BOOTSTRAP_ADMIN variables apply every time I run docker compose up?

No. The variables create a temporary bootstrap administrator when Keycloak initializes the master realm for the first time. Once the master realm exists, restarting or recreating the Keycloak container does not create another bootstrap administrator.

Does docker compose down delete realm data?

No. docker compose down removes containers and the project network but keeps the keycloak-compose_pgdata volume. Run docker compose down -v when you want to wipe PostgreSQL data and start fresh.

Why did my KC_* variable in .env not change Keycloak?

The project .env file is for Compose variable substitution in compose.yaml. Keycloak receives settings only through environment:, env_file:, or a mounted keycloak.conf. Add the variable under keycloak.environment or move it to an env_file: entry.

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)