Use case · Identity and SSO

Self-hosted Keycloak on a dedicated server: sizing, production mode, proxy paths

A self-hosted Keycloak for production runs kc.sh start (never start-dev) against PostgreSQL, behind a reverse proxy that exposes only /realms/, /resources/ and /.well-known/ on the public hostname, with the admin console on a second hostname limited to your own address and the management port 9000 kept off the network. Its CPU goes mostly into hashing passwords, so the sizing question is logins per second, not users. This page gives the Keycloak project's own sizing figures with the conditions they were measured under, a Docker Compose layout that keeps both secrets out of the process list, and the proxy rules the reverse proxy guide recommends.

See the machines

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:

LoadOfficial figureTested up to
Password-based logins1 vCPU per 15 per second300 per second
Client credential grants1 vCPU per 120 per second2,000 per second
Refresh token requests1 vCPU per 120 per second435 per second
Headroom150% extra CPU for spikes, startup and failover-
Memory per instance1,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

  1. Point two DNS records at the server: sso.example.com for logins and sso-admin.example.com for administration. Turn on ufw with SSH, 80 and 443 only before anything listens, and get one certificate covering both names.
  2. Put the Compose file that follows in /opt/keycloak/docker-compose.yml, and write the two secrets to /opt/keycloak/.env with mode 600. Compose reads it, so neither password is ever typed on a command line, where ps and shell history would show it. The database guide says a command-line database password is exposed and not recommended.
  3. Start the stack. start is 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).
  4. Keycloak's two ports are bound to 127.0.0.1 alone. Docker's -p rules bypass ufw, so a plain 8080:8080 would be public whatever the firewall says; a 127.0.0.1 binding also publishes nothing on IPv6.
  5. 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 -T turns off the TTY that compose exec allocates 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.

Put your sign-in on hardware you control

A whole machine with root, an out-of-band console on rack servers, and an unmetered port, billed monthly with no contract.

See the machines

Related on Server Room

Other use cases