This is the first article in a series about Keycloak. We will set up Keycloak as an authentication provider and later connect it to internal services. In the end we get a single entry point and a single user provider for all of them.
In this article we set up Keycloak itself, get the admin console running, and lock it down properly.
Installation
We run Keycloak with Docker, behind an existing nginx reverse proxy. This guide assumes such a proxy (e.g. jwilder/nginx-proxy with an ACME companion for SSL) is already running on the host and shared across services, listening on the webproxy docker network.
Make sure Docker and the Docker Compose plugin are installed on the server first:
docker --version
docker compose version
If these commands fail, install Docker using the official instructions for your OS. Once Docker is ready, create a folder on the server for the project, e.g. /opt/docker.keycloak. We will place the files there step by step: docker-compose.yml, .env, nginx/vhost.conf, and deploy.sh.
Configuration
docker-compose.yml:
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:26.2
container_name: keycloak
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: ${KC_DB_USERNAME:-keycloak}
KC_DB_PASSWORD: ${KC_DB_PASSWORD}
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_HOSTNAME: ${DOMAIN}
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HOSTNAME_STRICT: "false"
KC_HEALTH_ENABLED: "true"
JAVA_OPTS_APPEND: ${KC_JAVA_OPTS:--Xms512m -Xmx512m}
VIRTUAL_HOST: ${DOMAIN}
VIRTUAL_PORT: 8080
LETSENCRYPT_HOST: ${DOMAIN}
LETSENCRYPT_EMAIL: ${EMAIL}
depends_on:
postgres:
condition: service_healthy
networks:
- keycloak
- webproxy
healthcheck:
test: [ "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'" ]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
deploy:
resources:
limits:
memory: 1g
restart: unless-stopped
postgres:
image: postgres:17
container_name: keycloak_postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: ${KC_DB_USERNAME:-keycloak}
POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
volumes:
- keycloak_data:/var/lib/postgresql/data
networks:
- keycloak
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 512m
restart: unless-stopped
networks:
keycloak:
driver: bridge
webproxy:
external: true
volumes:
keycloak_data:
The setup has two containers: Keycloak and Postgres. Keycloak stores realm and user data in Postgres, so the database needs to be healthy before Keycloak starts. The webproxy network connects Keycloak to an external nginx reverse proxy.
Environment variables
.env:
DOMAIN=localhost
EMAIL=admin@example.com
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=
KC_DB_USERNAME=keycloak
KC_DB_PASSWORD=
-
DOMAIN: the domain for Keycloak (e.g.auth.example.com) -
EMAIL: email for the Let's Encrypt SSL certificate -
KEYCLOAK_ADMIN: Keycloak admin username -
KEYCLOAK_ADMIN_PASSWORD: admin password -
KC_DB_USERNAME: Postgres username (defaultkeycloak) -
KC_DB_PASSWORD: Postgres password
Fill in DOMAIN, EMAIL, and both passwords before the first run.
Restricting admin access
The admin console and the master realm give full control over Keycloak. If this is open to the web, it becomes a direct target for brute force and credential stuffing. The safe option is to close it from the outside and allow access only from 127.0.0.1, then reach it through an SSH tunnel when needed.
nginx/vhost.conf:
# Keycloak admin access restriction.
# Template: ${DOMAIN} is substituted by deploy.sh via envsubst.
#
# Admin interface is accessible only via SSH tunnel from localhost.
location /auth/admin/ {
allow 127.0.0.1;
deny all;
proxy_pass http://${DOMAIN};
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Proxy "";
proxy_http_version 1.1;
}
location /auth/realms/master/ {
allow 127.0.0.1;
deny all;
proxy_pass http://${DOMAIN};
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Proxy "";
proxy_http_version 1.1;
}
This config blocks the admin console and the master realm from the public internet. Only requests from 127.0.0.1 get through. This means the admin panel is reachable only through an SSH tunnel, which we set up below.
Deployment
With the config files ready, one script publishes the nginx vhost and starts Keycloak.
deploy.sh:
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VHOSTS_DIR="/srv/nginx-vhosts"
# Load .env
set -a
source "${SCRIPT_DIR}/.env"
set +a
if [[ -z "${DOMAIN:-}" ]]; then
echo "Error: DOMAIN is not set in .env" >&2
exit 1
fi
# Publish nginx vhost config
echo "Publishing nginx config for ${DOMAIN}..."
mkdir -p "${VHOSTS_DIR}"
envsubst '${DOMAIN}' < "${SCRIPT_DIR}/nginx/vhost.conf" > "${VHOSTS_DIR}/${DOMAIN}"
# Reload nginx
if docker inspect nginx_proxy &>/dev/null; then
docker exec nginx_proxy nginx -s reload
echo "nginx reloaded."
else
echo "Warning: nginx_proxy container not found, skipping reload."
fi
# Start Keycloak
echo "Starting Keycloak..."
docker compose -f "${SCRIPT_DIR}/docker-compose.yml" up -d
Run it with:
./deploy.sh
The script does three things:
- Fills
DOMAINfrom.envintonginx/vhost.confand writes the result to/srv/nginx-vhosts/${DOMAIN}. - Reloads
nginx_proxy. - Starts Keycloak with
docker compose up -d.
Admin console access
/auth/admin/ and /auth/realms/master/ are closed to the outside world. Only 127.0.0.1 can reach them.
Open an SSH tunnel from your local machine:
ssh -L 8443:127.0.0.1:443 user@server -N
Add this line to /etc/hosts:
127.0.0.1 auth.example.com
Open in the browser:
https://auth.example.com:8443/auth/admin/
Log in with the credentials from KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD. Close the tunnel with Ctrl+C when done.
Testing
After Keycloak starts, open the admin console through the SSH tunnel. You will see the Keycloak login screen.
Log in with the credentials from KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD.
After logging in, you start in the master realm. Do not use it for your applications and users: master controls Keycloak itself, including the admin account, and a leaked token from your applications should never reach it.
Create a separate realm for your organization instead. Click Create realm, give it a name (e.g. your company name), and save.
From this point, the realm is ready for clients, users, and roles. Everything you connect to Keycloak goes into this realm, not into master.
Conclusion
We now have a working Keycloak instance with a Postgres database and a locked down admin console. It can create realms, manage users, and issue tokens, which is everything a service needs to delegate its login screen to Keycloak.


Top comments (0)