What Keycloak needs from the machine
Keycloak is a Java server whose CPU time goes mostly into hashing the passwords users type. The project's sizing guide states its figures per second of load, for the whole cluster, and as a starting point to confirm with your own load tests:
| Load | Official figure | Tested up to |
|---|---|---|
| Password-based logins | 1 vCPU per 15 per second | 300 per second |
| Client credential grants | 1 vCPU per 120 per second | 2,000 per second |
| Refresh token requests | 1 vCPU per 120 per second | 435 per second |
| Headroom | 150% extra CPU for spikes, startup and failover | - |
| Memory per instance | 1,250 MB base, including realm caches and 10,000 cached sessions | - |
Scope: the reference architecture behind those numbers, described on the multi-cluster version of the guide, is a two-site active/active test: 3 pods on Red Hat OpenShift Service on AWS with Graviton (ARM) c7g instances, Amazon Aurora PostgreSQL across availability zones, default Argon2 hashing, 1,000,000 users and 20,000 clients, with user and client sessions stored in the database.
The guide expects slightly higher performance from a single-site setup, and reports that on x86 instances client credential grants and refresh tokens delivered up to twice the operations per core while password hashing stayed constant per core.
For a container, the containers guide recommends a 2 GB memory limit for smaller production deployments and says to always set one, because the heap is sized at 70% of the container memory. The database is sized separately: for every 100 login, logout or refresh requests per second the guide budgets 1,400 write IOPS and 0.35 to 0.7 vCPU, measured on Aurora. Supported PostgreSQL versions are 14.x to 18.x.
Our own arithmetic, not the guide's: a peak of 30 password logins a second is 2 vCPU of hashing, and 150% headroom on top makes 5 vCPU for Keycloak before the database. Measure your peak with the keycloak_credentials_password_hashing_validations_total metric once it runs.
Why a whole machine
The sizing guide reports that performance dropped significantly when Keycloak was held back from the CPU it needed; on shared hardware a busy neighbour can do the same. On a dedicated server there is no hypervisor and no second tenant on the hardware, so a login burst at 09:00 gets every core. The PostgreSQL that holds your users, clients and realm data sits on disks only you use.
- Root on the machine, and the operating system reinstalled from the control panel as often as needed.
- Every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), so a bad firewall rule still leaves you a way into your sign-in server.
- An unmetered port, on our own network AS19624, in New York, Miami, San Francisco, Amsterdam or Bucharest. Put it near the applications that call the token endpoint.
- Month to month. A racked machine is handed over in about 30 minutes once payment is approved; a built-to-order line takes 4 to 24 hours.
Which line fits
Size memory first, then cores. A single-socket rack server is the usual base for an application with its database on the same box, which is what Keycloak plus PostgreSQL is. Move to a dual-socket line when password logins at peak push past what one socket can hash with the 150% headroom left, or when the same machine also carries the applications Keycloak signs users into. For a database of its own on a second machine, see PostgreSQL on a dedicated server. To pick memory, disks and city part by part, use the custom build configurator.
Production setup with Docker Compose
- Point two DNS records at the server:
sso.example.comfor logins andsso-admin.example.comfor administration. Turn on ufw with SSH, 80 and 443 only before anything listens, and get one certificate covering both names. - Put the Compose file that follows in
/opt/keycloak/docker-compose.yml, and write the two secrets to/opt/keycloak/.envwith mode 600. Compose reads it, so neither password is ever typed on a command line, wherepsand shell history would show it. The database guide says a command-line database password is exposed and not recommended. - Start the stack.
startis production mode and refuses to run without a hostname. The containers guide says development mode,start-dev, should be strictly avoided in production because it has insecure defaults; the configuration guide lists them (HTTP enabled, strict hostname off, local-only caches). - Keycloak's two ports are bound to 127.0.0.1 alone. Docker's
-prules bypass ufw, so a plain8080:8080would be public whatever the firewall says; a 127.0.0.1 binding also publishes nothing on IPv6. - Wait until the management port reports ready, then enable the two nginx sites below and reload nginx.
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
sudo ufw enable
sudo certbot certonly --nginx -d sso.example.com -d sso-admin.example.com
sudo tee /etc/nginx/snippets/kc-headers.conf >/dev/null <<'EOF'
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Real-IP "";
EOF
sudo mkdir -p /opt/keycloak && cd /opt/keycloak
sudo sh -c 'umask 077; printf "DB_PASSWORD=%s\nKC_ADMIN_PASSWORD=%s\n" \
"$(openssl rand -base64 24)" "$(openssl rand -base64 24)" > .env'
# docker-compose.yml saved here, then:
sudo docker compose up -d
until curl --head -fsS http://127.0.0.1:9000/health/ready; do sleep 5; done
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:26.8.1
command: start
restart: unless-stopped
depends_on: [postgres]
mem_limit: 2g
environment:
KC_DB: postgres
KC_DB_URL_HOST: postgres
KC_DB_URL_DATABASE: keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASSWORD}
KC_HOSTNAME: https://sso.example.com
KC_HOSTNAME_ADMIN: https://sso-admin.example.com
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: temp-admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9000:9000"
volumes:
pgdata:
Here TLS ends at nginx on the same host and Keycloak speaks HTTP to it over loopback (edge termination, hence KC_HTTP_ENABLED). The reverse proxy guide considers edge termination less secure than re-encrypt and documents re-encrypt; if the proxy runs on another machine, give Keycloak its own certificate with --https-certificate-file instead.
The proxy: two hostnames, few paths
The reverse proxy guide lists what to expose: /realms/, /resources/ and /.well-known/ yes; / no; /admin/ and /realms/master/ only internally; /metrics and /health no. /lb-check is only for the stateless or multi-site features, which one server does not use. Set --proxy-headers (here KC_PROXY_HEADERS: xforwarded), or origin-checked requests through the proxy get 403, and make the proxy overwrite the forwarded headers rather than append to them, which the shared header snippet above does.
Administration follows the hostname guide's separate-hostname pattern: KC_HOSTNAME_ADMIN sends the Admin Console to sso-admin.example.com. That option alone does not stop the Administration REST API answering on the public name; the guide says that if you want to restrict it, you need to do it on the reverse proxy level. So the public server below refuses /admin/ and /realms/master/, and the admin server answers only the address in its allow line (replace 203.0.113.10 with yours).
server { # public: logins and OIDC only
listen 443 ssl;
server_name sso.example.com;
ssl_certificate /etc/letsencrypt/live/sso.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sso.example.com/privkey.pem;
include /etc/nginx/snippets/kc-headers.conf;
location /realms/master/ { return 404; }
location /realms/ { proxy_pass http://127.0.0.1:8080; }
location /resources/ { proxy_pass http://127.0.0.1:8080; }
location /.well-known/ { proxy_pass http://127.0.0.1:8080; }
location / { return 404; }
}
server { # admin: your own IP only
listen 443 ssl;
server_name sso-admin.example.com;
ssl_certificate /etc/letsencrypt/live/sso.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sso.example.com/privkey.pem;
include /etc/nginx/snippets/kc-headers.conf;
allow 203.0.113.10;
deny all;
location /admin/ { proxy_pass http://127.0.0.1:8080; }
location /realms/master/ { proxy_pass http://127.0.0.1:8080; }
location /resources/ { proxy_pass http://127.0.0.1:8080; }
location / { return 404; }
}
nginx matches the longest prefix, so /realms/master/ returns 404 on the public name while every other realm is served. The guide's header table also lists tracing headers such as traceparent and b3 to strip; add them to the snippet if your stack does not need them.
Bootstrap admin, then replace it
KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD create an account only on the first start, while the master realm does not exist yet, and that account is temporary: the admin console flags it with a warning banner. From your own address, open https://sso-admin.example.com/admin/, sign in as temp-admin, create your own admin user in the master realm, give it the admin role and a second factor, sign in as it, delete temp-admin, and remove the bootstrap password from .env. Keep the master realm for administering Keycloak and create a separate realm for your applications.
Port 9000 serves /health/ready, /health/live and, with metrics enabled, /metrics. The proxy guide says not to proxy it, so a monitor on the server itself polls http://127.0.0.1:9000/health/ready; the Keycloak image has no curl, so run the check from the host.
Running it, and where it falls short
- Back up the database: it is the whole state. A root crontab line:
cd /opt/keycloak && docker compose exec -T postgres pg_dump -U keycloak keycloak | gzip > /var/backups/keycloak-$(date +\%F).sql.gz. The%is escaped because cron treats it specially, and-Tturns off the TTY thatcompose execallocates by default. - Pin the image tag and read the release notes before raising it. A new version can migrate the schema on startup, so dump the database first.
- A single Keycloak node has no failover. The production guide describes two or more Keycloak instances so users can still log in when one goes down; if every application depends on this sign-in, plan a second server, our GitLab use case being one of the applications that would stop with it.
Questions
How much RAM does a production Keycloak need?
The sizing guide puts the base at 1,250 MB per instance, including realm caches and 10,000 cached sessions, and the containers guide recommends a 2 GB container memory limit for smaller production deployments. PostgreSQL needs its own memory on top. More cached sessions or clients raise the figure.
Can I run start-dev behind a proxy and call it production?
No. The containers guide says development mode should be strictly avoided in production because it has insecure defaults, and the configuration guide lists them: HTTP enabled, strict hostname resolution off, local-only caches. Its default dev-file database is, per the database guide, not suitable for production either. Use start with a hostname and PostgreSQL.
Should port 9000 be open in the firewall?
No. It carries health checks and metrics, and the reverse proxy guide says not to proxy it so that information does not reach external callers. Publish it on 127.0.0.1 or leave it unpublished, and remember that Docker port publishing bypasses ufw.
Where do the database and admin passwords go?
In a root-owned file nobody else may read, such as a Compose .env with mode 600, or in conf/keycloak.conf outside containers. Passed as command-line flags they show up in ps output and shell history. Anyone allowed to run docker inspect can still read container environment variables, so limit who is in the docker group.
Is the admin console safe to put on the public hostname?
The production guide treats it as best practice to serve the Admin Console and Admin REST API on a different hostname or context path, and the proxy guide marks /admin/ and /realms/master/ as internal only. This page gives administration its own hostname and lets nginx answer it only for your address.