VPS Tutorials
Deploy Keycloak Single Sign-On on a VPS (2026): Docker, PostgreSQL, Caddy HTTPS, OIDC and Backups
Deploy Keycloak 26.7.2, PostgreSQL 17 and Caddy HTTPS on an Ubuntu VPS with Docker Compose, configure realms, OIDC, MFA, roles, backups and secure upgrades, and build a self-hosted single sign-on entry point for your team.

Many teams already run GitLab, Grafana, Jenkins, internal admin panels and self-hosted AI applications, yet still create separate accounts in each system. When an employee leaves, each account must be disabled one by one, password policies differ everywhere, and unified auditing is difficult. The value of Keycloak is not adding yet another login page, but centralizing accounts, single sign-on, MFA, roles and session policies so that applications supporting OpenID Connect (OIDC) or SAML share one identity entry point.
This article uses Keycloak 26.7.2, PostgreSQL 17, Docker Compose, Caddy and Ubuntu 24.04 as its baseline to build an SSO service suitable for small production environments on a single VPS, and fully demonstrates domain HTTPS, temporary administrator handling, realms, OIDC clients, redirect URIs, roles, backups, upgrades and troubleshooting. The Keycloak version and official support matrix were checked as of September 2026; before actually deploying, you should still review the latest security advisories and release notes.
Keycloak is an identity and access management system, not an ordinary password manager. It can provide unified login for multiple applications and centrally handle:
- OpenID Connect, OAuth 2.0 and SAML 2.0 authentication;
- users, groups, realm roles, client roles and attribute management;
- TOTP, WebAuthn, password policies, brute-force detection and session control;
- integration with external identity sources such as LDAP, Active Directory, GitHub and Google;
- user self-service profile editing, password reset and second-factor management;
- admin events, login events and fine-grained administrator permissions.
If you only want to add a login gate to a single web page that does not support OIDC, Authelia or reverse-proxy Basic Auth is usually lighter. If the application itself supports OIDC and you need unified user lifecycle, organizational permissions or future integration with more systems, Keycloak offers more room to grow.
| Option | Advantages | Main costs | Better suited for |
|---|---|---|---|
| Keycloak | Complete OIDC/SAML, rich realms, roles, identity sources and policies | Many configuration options, Java service needs explicit memory limits | Multiple applications, team accounts, enterprise identity integration |
| Authentik | Friendly admin UI, full proxy and directory capabilities | Also has many components and concepts | Teams that want visual flow orchestration for login |
| Authelia | Lightweight, good for protecting web pages behind a reverse proxy | Not a complete enterprise identity platform | A small number of internal web pages needing unified front authentication |
| Each application manages its own accounts | Fastest to start | Offboarding, MFA, auditing and password policies are scattered | Only one application with very few users |
This article uses a single-node architecture:
浏览器 / 应用
|
| HTTPS :443
v
Caddy
|
| HTTP 127.0.0.1:8080
v
Keycloak 26.7.2
|
| Docker 内部网络 :5432
v
PostgreSQL 17
健康检查:仅本机访问 127.0.0.1:9000
Caddy terminates TLS on the VPS, and Keycloak serves HTTP only over the loopback address. PostgreSQL does not map any host port at all. The Keycloak management interface's 9000 binds only to 127.0.0.1, is not placed behind Caddy, and is not exposed to the public internet.
The official Keycloak container calculates the JVM heap based on the container memory limit: the maximum heap defaults to about 70% of the limit, plus non-heap overhead. The official guidance for small production deployments is a 2GB container memory limit. Considering PostgreSQL, Caddy, system cache and backup tasks, start from the following configurations:
| Usage scale | Suggested VPS starting point | Notes |
|---|---|---|
| Testing, fewer than 20 users | 2 vCPU, 4GB RAM, 40GB NVMe | Not an availability guarantee, not recommended for critical logins |
| Small team, hundreds of users, low concurrency | 4 vCPU, 8GB RAM, 80GB NVMe | Keycloak limited to 2GB, leaving ample headroom for database and system |
| Obvious login peaks or many identity sources | Load test first, then scale up or go multi-node | Password hashing, external directories and custom plugins all affect capacity |
A single VPS is still a single point of failure. As long as all applications depend on it for login, Keycloak unavailability will affect new sessions. Critical business should use an external PostgreSQL, at least two Keycloak nodes, load balancing and off-machine backups; do not mistake the single-node setup in this article for a high-availability architecture.
Use a dedicated domain for SSO, for example sso.example.com. Add an A record at your DNS provider pointing to the VPS public address; add an AAAA record only if the VPS actually has working IPv6 configured.
Very few ports are needed:
| Port | Source | Purpose | Public |
|---|---|---|---|
22/TCP | Admin IP | SSH | Best restricted by source |
80/TCP | Public | Caddy certificate validation and HTTP redirect | Yes |
443/TCP | Public or specified network | Keycloak login and OIDC/SAML endpoints | Yes |
8080/TCP | Caddy host | Keycloak HTTP | No, loopback only |
9000/TCP | Local monitoring | Health checks and metrics | No, loopback only |
5432/TCP | Docker internal network | PostgreSQL | No, not mapped |
If the cloud security group already allows traffic but access still fails, check layer by layer following the port, firewall, security group and listening address troubleshooting guide; do not simply disable the firewall to verify.
The following commands use Ubuntu 24.04 as an example. Docker should be installed from the official repository; if the VPS is already running Docker, do not re-run third-party scripts that would overwrite the repository configuration.
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl openssl caddy jq
docker --version
docker compose version
sudo systemctl enable --now caddy
First confirm that docker compose is Compose v2. Production environments should also configure log rotation, resource limits, health checks and a rollback process; you can follow the Docker Compose production configuration checklist alongside this.
Create a directory accessible only to administrators:
sudo install -d -m 750 -o "$USER" -g "$USER" /opt/keycloak
cd /opt/keycloak
umask 077
Generate the database password and temporary administrator password. Hexadecimal characters are used here to avoid characters such as $ being expanded again by Compose as variables:
POSTGRES_PASSWORD_VALUE="$(openssl rand -hex 32)"
BOOTSTRAP_PASSWORD_VALUE="$(openssl rand -hex 32)"
cat > .env <<EOF
KC_HOSTNAME=sso.example.com
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=${POSTGRES_PASSWORD_VALUE}
KC_BOOTSTRAP_ADMIN_USERNAME=temp-admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_PASSWORD_VALUE}
EOF
chmod 600 .env
unset POSTGRES_PASSWORD_VALUE BOOTSTRAP_PASSWORD_VALUE
Replace sso.example.com with your real domain. Do not commit .env to Git, and do not paste it into chats, screenshots or tickets. Keycloak officially also supports storing sensitive configuration in a Java KeyStore; this article uses a permission-restricted environment file to make the single-machine deployment steps easier to reproduce.
When confirming file permissions and the Compose expansion result, do not output real passwords directly:
stat -c '%a %U:%G %n' .env
grep -E '^(KC_HOSTNAME|POSTGRES_DB|POSTGRES_USER|KC_BOOTSTRAP_ADMIN_USERNAME)=' .env
Keycloak officially recommends running build first, then starting with start --optimized. This way build-time options such as database drivers, health checks and metrics are written into the image in advance, and startup does not need to repeat detection and building.
Create /opt/keycloak/Containerfile:
FROM quay.io/keycloak/keycloak:26.7.2 AS builder
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.7.2
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
Do not use latest in production. This article pins Keycloak 26.7.2; when upgrading, change both FROM, rebuild, and complete database backup and rollback testing.
Create /opt/keycloak/compose.yaml:
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- keycloak_internal
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
keycloak:
build:
context: .
dockerfile: Containerfile
image: local/keycloak:26.7.2
restart: unless-stopped
command: ["start", "--optimized"]
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
KC_HOSTNAME: https://${KC_HOSTNAME}
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
depends_on:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:9000:9000"
mem_limit: 2g
cpus: 2.0
healthcheck:
test:
- CMD-SHELL
- "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000"
interval: 30s
timeout: 5s
retries: 10
start_period: 60s
networks:
- keycloak_internal
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks:
keycloak_internal:
driver: bridge
volumes:
postgres_data:
There are several key points here:
- PostgreSQL 17 is within the PostgreSQL 14–18 range officially supported by Keycloak 26.7.2;
- The database has no
portsand can only be accessed by Keycloak on the internal network; KC_HTTP_ENABLED=trueonly allows Caddy and Keycloak to communicate in plaintext on the local machine; the public internet still uses HTTPS;KC_HOSTNAMEis written as a full external URL to avoid errors in issuer, redirect and static resource addresses;KC_PROXY_HEADERS=xforwardedlets Keycloak parse theX-Forwarded-*set by Caddy;9000binds only to the loopback address for host checks, not exposed through Caddy;- Keycloak is set to a 2GB memory limit to prevent the JVM from expanding the heap based on the entire host's memory.
If the 2GB limit causes memory pressure on other services on the same machine, do not simply lower it to a few hundred MB and pretend it is production-ready. Migrate other services, add VPS memory, or run real load tests first.
First render the Compose configuration. The rendered result contains sensitive information, so use it only for local checks and delete it immediately after viewing:
cd /opt/keycloak
docker compose config --quiet
docker compose build --pull keycloak
docker compose up -d
docker compose ps
The first startup initializes the database and the master realm, which is usually slower than subsequent startups. Follow the logs continuously:
docker compose logs -f --tail=200 keycloak
After normal startup messages appear, press Ctrl+C to exit the logs without stopping the container. Check the main interface and management interface:
curl -fsSI http://127.0.0.1:8080/
curl -fsS http://127.0.0.1:9000/health/ready | jq
curl -fsS http://127.0.0.1:9000/health/live | jq
ss -lnt | grep -E ':8080|:9000'
/health/ready should return HTTP 200 with status UP. 8080 and 9000 should listen only on 127.0.0.1. The official Keycloak image does not install curl to reduce the attack surface, so the in-container health check in Compose uses the Bash TCP method given in the official documentation; host checks can still use curl.
If Keycloak shows unhealthy, first look at:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q keycloak)" | jq
docker compose logs --tail=200 postgres keycloak
Do not use restart: always to mask database password, hostname or migration errors. A container that keeps restarting only makes the logs harder to read.
Create or edit /etc/caddy/Caddyfile:
sso.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
Replace the domain with the value in .env. Caddy sets or extends X-Forwarded-For by default, and sets X-Forwarded-Proto and X-Forwarded-Host; Keycloak already parses these headers via KC_PROXY_HEADERS=xforwarded. Do not let untrusted clients bypass Caddy and connect directly to 8080.
Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager
curl -fsSI https://sso.example.com/
If certificate issuance fails, check whether DNS has taken effect, whether ports 80/443 are occupied by other services, whether the cloud security group allows them, and whether an erroneous AAAA record points requests to unreachable IPv6. For multi-site configuration and certificate troubleshooting, see the complete guide to Caddy automatic HTTPS.
Keycloak officially explicitly advises against exposing the management port 9000 through a reverse proxy. Do not configure reverse_proxy 127.0.0.1:9000, and do not allow it in the security group.
Open:
https://sso.example.com/admin/
Log in with KC_BOOTSTRAP_ADMIN_USERNAME and KC_BOOTSTRAP_ADMIN_PASSWORD from .env. Current Keycloak treats accounts created via startup parameters as temporary administrators; they should only be used to create the master realm for the first time, and are not recreated on every startup just because the environment variables still exist.
Immediately after logging in, complete the following:
- Create a new permanent administrator in the
masterrealm, using a personal account rather than a sharedadmin; - Enable TOTP or WebAuthn for the permanent administrator, and confirm you can log in again in an incognito window;
- Assign the permanent administrator only the admin roles it needs, avoiding daily accounts having permissions beyond their responsibilities;
- After successfully logging in with the permanent administrator, delete
temp-admin; - Delete the two
KC_BOOTSTRAP_ADMIN_*lines from.env, then rebuild the Keycloak container; - Write the permanent administrator recovery process into the team's offline operations documentation.
After editing .env to remove the temporary administrator variables, also delete the corresponding two environment variable lines from compose.yaml, then run:
cd /opt/keycloak
docker compose config --quiet
docker compose up -d --force-recreate keycloak
docker compose logs --tail=100 keycloak
Deleting only the environment variables does not automatically delete the temporary account; you must delete it manually in the admin console. Conversely, do not delete the temporary account before verifying the permanent administrator, or you may lock yourself out of the system.
Do not put ordinary users and business applications directly into the master realm. master is mainly used to manage other realms; business should create a separate realm, for example company.
In the realm dropdown at the top left, select Create realm:
- Realm name:
company - Enabled: on
The realm name becomes part of public endpoint URLs, so try not to change it after creation. Use a short lowercase ASCII name, and do not include company secrets or environment descriptions.
After creation, check at least the following items:
- Realm settings → Login: whether self-registration, forgot password and email login are allowed;
- Realm settings → Email: configure SMTP and test password reset emails;
- Authentication → Policies: password length, character and password history policies;
- Realm settings → Security defenses: enable brute-force detection and set reasonable thresholds;
- Realm settings → Tokens: adjust Access Token, SSO Session and Offline Session according to risk;
- Events: record login failures and admin changes, and plan a retention period.
Do not enable self-registration, loose passwords, long sessions and unlimited retries all at once for the sake of "login convenience". Once SSO centralizes the entry point, the security of that entry point also affects every connected application.
Assume the application to integrate is at:
https://app.example.com
Its documentation requires the redirect URI to be:
https://app.example.com/oauth/callback
Switch to the company realm and go to Clients → Create client:
- Client type:
OpenID Connect - Client ID:
internal-app - Client authentication: on for server-side applications; off for pure browser public clients per the application documentation
- Standard flow: usually on for browser authorization code login
- Direct access grants: off unless there is a clear need
- Valid redirect URIs:
https://app.example.com/oauth/callback - Web origins: only
https://app.example.com
After saving, server-side applications can obtain the Client Secret under Credentials. The secret may only be placed in server-side environment variables or a secrets management system, never in browser JavaScript, public repositories or frontend images.
Redirect URIs must follow the application's official documentation. The safest approach is to enter exact addresses; do not write * just to save effort. The official Keycloak admin guide explicitly warns that using fully wildcard redirects in production makes it easier for attackers to construct malicious redirects.
Most applications only need the following four items:
| Parameter | Example value |
|---|---|
| Issuer | https://sso.example.com/realms/company |
| Discovery URL | https://sso.example.com/realms/company/.well-known/openid-configuration |
| Client ID | internal-app |
| Client Secret | The value generated under Credentials |
First verify the discovery document from the VPS or the application server:
curl -fsS \
https://sso.example.com/realms/company/.well-known/openid-configuration \
| jq '{issuer,authorization_endpoint,token_endpoint,jwks_uri,end_session_endpoint}'
The issuer in the output must be:
https://sso.example.com/realms/company
If it shows http://127.0.0.1:8080, an internal container name or the wrong domain, fix KC_HOSTNAME and the proxy headers first, then connect the application. Do not disable issuer or TLS verification in the application to work around the error.
Keycloak commonly has three layers of permission objects:
- Group: represents an organizational structure or set of people, for example
engineering,finance; - Realm Role: a role shared across multiple applications, for example
employee,security-auditor; - Client Role: belongs only to a specific client, for example
grafana-admin,wiki-editor.
It is recommended to add users to groups and then map roles to groups, rather than manually assigning dozens of roles to each user. For example:
Group: engineering
├─ Realm Role: employee
├─ Client Role: grafana-viewer
└─ Client Role: internal-app-editor
This way, when an employee changes roles, only the group needs adjusting. The application must still validate the token, issuer, audience, signature and roles in its own backend; "the user can log in from Keycloak" does not mean "the user automatically has application admin permissions".
If the application does not see the expected roles, check whether the Client Scope and Protocol Mapper put the corresponding claims into the ID Token or Access Token. Do not indiscriminately stuff all roles and user attributes into the token; oversized cookies or request headers can cause the reverse proxy to return 431 Request Header Fields Too Large.
An identity entry point controlling multiple systems should at minimum enforce MFA for administrators. You can enable Configure OTP under Authentication → Required actions, then require specific user groups to configure TOTP via default user actions or a conditional authentication flow.
Password policies can start from the following baseline and be adjusted to organizational risk:
- minimum 14 characters;
- reject recently used passwords;
- enable brute-force detection;
- enforce MFA for administrators;
- do not share administrator accounts;
- use short validity for email reset links;
- keep Access Tokens short and rely on secure Refresh Tokens for renewal;
- shorten idle sessions and maximum sessions for high-risk applications.
Do not blindly set Access Tokens to several hours. Once a token leaks, it can be used until it expires or is effectively revoked. Also do not only shorten Access Tokens while letting applications log Client Secrets and Refresh Tokens.
Go to Realm settings → Email and fill in SMTP Host, Port, From, authentication account and encryption method. Click test connection and actually create an ordinary test user to verify:
- Verification emails are delivered;
- Password reset links use the correct HTTPS domain;
- Links cannot be used after expiry;
- Emails do not leak internal hostnames;
- The sending domain has SPF, DKIM and DMARC configured.
The SMTP password, like the database password, is sensitive configuration. If injected via environment variables or KeyStore, do not leak it in docker compose config output, terminal history or monitoring logs.
This article enables health and metrics at build time. The host can check:
curl -fsS http://127.0.0.1:9000/health/ready | jq
curl -fsS http://127.0.0.1:9000/health/live | jq
curl -fsS http://127.0.0.1:9000/metrics | head
The four common health endpoints have different meanings:
| Endpoint | Purpose |
|---|---|
/health/started | Startup probe, determines whether the startup process is complete |
/health/live | Liveness probe, determines whether the process can still run |
/health/ready | Readiness probe, determines whether it can accept new traffic |
/health | Aggregated health information |
A temporary failure of ready does not necessarily mean the container should be restarted immediately; when the database briefly hiccups, restarting amplifies the failure. The official documentation also states that container health checks based on ready are suitable for determining whether to accept traffic, and should not be directly equated with "must restart".
Externally, you should also check from another machine:
curl -fsS \
https://sso.example.com/realms/company/.well-known/openid-configuration \
>/dev/null
External checks can detect DNS, certificate, Caddy and public network link failures, while the local /health/ready only shows that Keycloak itself is ready. You can connect external liveness checks to Uptime Kuma, and feed JVM, database connection pool and login error rates into Prometheus or your existing monitoring platform.
Keycloak's database contains sensitive data such as users, password hashes, Client Secrets, realm signing keys and sessions. Backups must be encrypted, access-restricted and copied to another machine or object storage.
Create the backup directory:
sudo install -d -m 700 -o "$USER" -g "$USER" /opt/keycloak/backups
cd /opt/keycloak
Run a logical backup:
set -a
. ./.env
set +a
BACKUP_FILE="/opt/keycloak/backups/keycloak-$(date +%F-%H%M%S).dump"
docker compose exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc \
> "$BACKUP_FILE"
chmod 600 "$BACKUP_FILE"
pg_restore --list "$BACKUP_FILE" | head
unset POSTGRES_PASSWORD KC_BOOTSTRAP_ADMIN_PASSWORD
Also back up the following, but do not mix plaintext .env with public documentation:
compose.yaml,Containerfileand the Caddyfile;- the encrypted
.envor external secrets management system configuration; - custom themes, Provider JARs and their exact versions;
- the PostgreSQL dump;
- version numbers, recovery steps and the most recent drill record.
Keycloak's realm JSON export can assist with migration and configuration review, but the official documentation explicitly states it has consistency and data scope limitations: online exports cannot guarantee consistency and do not include all sessions, events, workflow states and revoked tokens. Therefore, realm exports cannot replace database backups.
If a full CLI export is truly needed, schedule a maintenance window to stop Keycloak first, then run the official export command. The admin console's Partial export masks passwords and Client Secrets and is also not suitable as a complete disaster recovery backup.
A recovery drill should at least verify:
- Create an empty PostgreSQL in an isolated environment;
- Import the backup using
pg_restore; - Start with the same Keycloak version;
- Verify realms, users, clients, roles and signing keys;
- Complete login, refresh and logout with a test application;
- Record recovery time and failure points.
If a backup has never been restored successfully, it is only a "possibly usable file". You can refer to the VPS backup and recovery drill guide to chain database, configuration and off-site storage into a scheduled task.
An identity service should not stay on old versions for long, but you should also not pull a new image directly without backups and a rollback plan. Before upgrading:
- Read the target version's release notes, upgrade guide and security advisories;
- Back up PostgreSQL, configuration, custom themes and Providers;
- Verify the upgrade in a test environment using a copy of the production backup;
- Check whether custom extensions support the target version;
- Record the current image ID, Keycloak version and PostgreSQL version;
- Prepare database recovery, not just image rollback.
Check the current version and image:
cd /opt/keycloak
docker compose exec keycloak /opt/keycloak/bin/kc.sh --version
docker compose images
docker inspect local/keycloak:26.7.2 --format '{{.Id}}'
When upgrading, change both Keycloak version occurrences in Containerfile, build first but do not delete the old image immediately:
docker compose build --pull keycloak
docker compose up -d keycloak
docker compose logs -f --tail=200 keycloak
After completing health checks, admin console login and real OIDC login, then clean up the old image. Keycloak startup may modify the database schema, so simply reverting to the old image may not roll back; the reliable approach is to restore the pre-upgrade database and use the old image together.
First confirm whether Keycloak is running and whether the port listens only on loopback:
cd /opt/keycloak
docker compose ps
docker compose logs --tail=200 keycloak
curl -fsSI http://127.0.0.1:8080/
ss -lnt | grep ':8080'
If accessing 127.0.0.1:8080 from the host also fails, the problem is in Keycloak, the database or Compose, not Caddy. If the local check is fine, then check the Caddyfile, SELinux/AppArmor and Caddy logs.
Confirm:
KC_HOSTNAME: https://${KC_HOSTNAME}
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
Also confirm that users can only access the service through Caddy, and that no other proxy overrides X-Forwarded-Proto. If a CDN sits in front of Caddy, trust only the CDN's officially published egress ranges, not forwarding headers from arbitrary clients.
Compare the redirect_uri actually sent by the application with the Keycloak client's Valid redirect URIs. Protocol, domain, port, path and trailing slash can all affect matching:
https://app.example.com/oauth/callback
https://app.example.com/oauth/callback/
These are two different addresses. Prefer adding exact addresses; do not use * as a temporary workaround and forget to tighten it.
Check the discovery document:
curl -fsS \
https://sso.example.com/realms/company/.well-known/openid-configuration \
| jq -r .issuer
The Issuer configured in the application must match character by character. Do not treat the Discovery URL as the Issuer, and do not mix in the old /auth/realms/... path.
Check in order:
- Whether the user belongs to the correct Group;
- Whether the Group maps Realm Roles or Client Roles;
- Whether the application reads the ID Token or the Access Token;
- Whether the Client Scope / Mapper includes the required claims;
- Whether the application expects
realm_access.roles,resource_accessor custom claims; - Whether the user logged in again to obtain a new token after role changes.
Do not judge authorization security just by decoding the JWT in the browser. The backend must also validate the signature, issuer, audience, expiry and authorization rules.
Check the logs but do not print passwords directly:
cd /opt/keycloak
docker compose logs --tail=200 postgres keycloak
docker compose exec postgres pg_isready -U keycloak -d keycloak
If the database volume has already been initialized, later changing only the password in .env will not automatically change the database user password. You should explicitly perform a password change, or rebuild when confirmed there is no data and you have authorization; do not delete the volume as an ordinary troubleshooting step.
Confirm that the Compose mem_limit: 2g is in effect:
docker inspect "$(docker compose ps -q keycloak)" \
--format '{{.HostConfig.Memory}}'
docker stats --no-stream
The official container calculates the heap based on the memory limit; without a limit, the JVM may expand based on the host's total memory. If OOM does occur, first analyze login concurrency, caching, custom Providers and database latency, then adjust the limit; do not just add Swap to mask the problem.
Readiness checks cannot replace real business checks. Continue verifying:
- Public DNS and certificates;
- Discovery URL;
- The test client's authorization code flow;
- External LDAP/identity sources;
- SMTP or MFA services;
- Application redirect and backend token validation.
It is recommended to use a dedicated test realm or test account for synthetic login monitoring, so that monitoring scripts do not hold production admin permissions.
Confirm each item before going live:
- Keycloak uses an explicit version
26.7.2, notlatest; - PostgreSQL does not map public ports;
-
8080and9000bind only to127.0.0.1; - Caddy HTTPS works and the domain exactly matches
KC_HOSTNAME; -
KC_PROXY_HEADERS=xforwardedis configured; - The management port
9000is not exposed by Caddy; - A permanent personal administrator has been created and MFA enabled;
- The temporary administrator has been deleted and startup variables removed;
- Business users and applications are not placed in the
masterrealm; - OIDC redirect URIs and Web Origins use exact values;
- Client Secrets have not entered the frontend, Git or logs;
- Password policies, brute-force detection and reasonable session lifetimes are enabled;
- SMTP, password reset and MFA have been actually tested;
- Database and configuration have encrypted off-machine backups;
- A recovery drill has been completed in an isolated environment;
- Both internal health checks and the external login path are monitored;
- A database-level rollback plan exists before upgrades.
The official container recommends a 2GB memory limit for small production deployments, with the maximum JVM heap defaulting to about 70% of the container limit. Test environments can be lower, but when Keycloak, PostgreSQL and the system share a machine, the whole VPS should start from 4GB; to carry a team's critical logins, 8GB leaves more reasonable room for the database, cache and upgrades. Final capacity should be based on load testing with real concurrent logins, password hashing policies, identity sources and plugins.
Not recommended. start-dev lowers security thresholds for local development. Production should use start, configure a fixed hostname, HTTPS or controlled reverse-proxy TLS termination, a production database, and follow the official recommendation to run build first and then start --optimized.
Because this article uses edge TLS termination: the browser-to-Caddy connection is HTTPS, and Caddy-to-Keycloak on the same machine, bound only to loopback, is HTTP. Keycloak production mode disables HTTP by default, so it must be explicitly enabled, along with setting the full external hostname and KC_PROXY_HEADERS=xforwarded. The public internet cannot access 8080 directly.
No. The official documentation states that exports have consistency and data scope limitations and do not include all events, persistent sessions, workflow states and revoked tokens. Disaster recovery should center on encrypted PostgreSQL backups, with realm exports as a supplement for migration or configuration review.
Yes. Each application usually gets its own client, configured with exact redirect URIs, Web Origins, secrets and roles. Environments with multiple departments or very different security boundaries can be split into multiple realms, but users, sessions and configuration are isolated between realms, so consider account synchronization and operational costs before splitting.
It can serve as a starting point for small teams that can accept brief login interruptions, but it is not high availability. Whether existing sessions remain usable depends on the application's token and session design; new logins, token refreshes and logouts are usually affected by Keycloak failures. Critical business should deploy multiple Keycloak nodes, an external highly available database, load balancing and a rehearsed recovery process.
- Keycloak downloads and current version
- Running Keycloak in a container
- Keycloak production configuration
- Keycloak reverse proxy configuration
- Keycloak Hostname v2
- Databases and platforms supported by Keycloak
- Keycloak health checks
- Keycloak temporary admin and recovery
- Keycloak realm import and export
- Caddy reverse_proxy documentation
The standard for a successful Keycloak deployment is not "the admin console opens", but that the external hostname is correct, redirect URIs are tightened, the temporary administrator is cleaned up, MFA and account offboarding processes are executable, the database can be recovered, upgrades can be rolled back, and the real OIDC login path is monitored. Get this minimal loop working first, then connect GitLab, Grafana, Jenkins or self-hosted applications; later expansion will be much safer.