This guide covers a full production deployment of Keycloak 26.7.0 on AlmaLinux 9. We’ll use OpenJDK 25 as the runtime, Percona Distribution for PostgreSQL 17 as the database, run Keycloak as a systemd service, and secure it behind an Nginx reverse proxy with a free Let’s Encrypt TLS certificate.
Placeholders used throughout this guide: Wherever you see values like
your-domain.example.com,YOUR_STRONG_DB_PASSWORD, orYOUR_ADMIN_PASSWORD, replace them with your own. A summary of all placeholders is provided below.
| Placeholder | Description | Example |
|---|---|---|
your-domain.example.com | Your Keycloak FQDN (must have a DNS A record pointing to your server’s public IP) | sso.example.com |
YOUR_STRONG_DB_PASSWORD | Password for the PostgreSQL kc_user database user | Use a password manager to generate one |
YOUR_ADMIN_USERNAME | Keycloak admin console username | admin |
YOUR_ADMIN_PASSWORD | Keycloak admin console password | Use a strong, unique password |
Prerequisites
- AlmaLinux 9 (fresh install recommended), minimum 2 vCPU / 4 GB RAM for small-to-medium workloads.
- Root access or a user with
sudoprivileges. - A domain name (e.g.,
your-domain.example.com) with an A record pointing to your server’s public IP. - Ports 80 and 443 open to the internet for Let’s Encrypt validation and HTTPS traffic.
Component Versions
| Component | Version |
|---|---|
| OS | AlmaLinux 9 |
| Java | OpenJDK 25 |
| Database | PostgreSQL 17 (Percona Distribution) |
| Keycloak | 26.7.0 |
| Web Server | Nginx (reverse proxy) |
| SSL | Let’s Encrypt (Certbot) |
1. System Preparation
Update existing packages and install basic utilities:
sudo dnf update -ysudo dnf install -y wget curl unzip tar policycoreutils-python-utilsOptionally, set the hostname for log and certificate consistency:
# Replace with your actual domainsudo hostnamectl set-hostname your-domain.example.com2. Install OpenJDK 25
The default AlmaLinux 9 AppStream repository only provides up to OpenJDK 21. For OpenJDK 25, we’ll download the official build directly.
2.1 Download & Extract
cd /optsudo wget https://download.java.net/java/GA/jdk25.0.2/b1e0dfa218384cb9959bdcb897162d4e/10/GPL/openjdk-25.0.2_linux-x64_bin.tar.gz
sudo tar -xzf openjdk-25.0.2_linux-x64_bin.tar.gzsudo mv jdk-25.0.2 /opt/jdk-25sudo rm openjdk-25.0.2_linux-x64_bin.tar.gzTip: The latest build download links are always available at https://jdk.java.net/25/ — check for the latest build number before installing, as the URL changes with each update release.
2.2 Register via alternatives
sudo alternatives --install /usr/bin/java java /opt/jdk-25/bin/java 2500sudo alternatives --install /usr/bin/javac javac /opt/jdk-25/bin/javac 2500
sudo alternatives --set java /opt/jdk-25/bin/javasudo alternatives --set javac /opt/jdk-25/bin/javac2.3 Set JAVA_HOME System-Wide
sudo tee /etc/profile.d/java25.sh > /dev/null <<'EOF'export JAVA_HOME=/opt/jdk-25export PATH=$JAVA_HOME/bin:$PATHEOF
source /etc/profile.d/java25.sh2.4 Verify
java --versionExpected output:
openjdk 25.0.2 2026-01-20OpenJDK Runtime Environment (build 25.0.2+10-69)OpenJDK 64-Bit Server VM (build 25.0.2+10-69, mixed mode, sharing)3. Install & Configure PostgreSQL 17 (Percona Distribution)
As an alternative to the community PostgreSQL (PGDG) packages, this guide uses Percona Distribution for PostgreSQL — a 100% upstream-compatible PostgreSQL build from Percona, bundled with operational extensions (pgBackRest, pg_stat_monitor, pgAudit, Patroni, etc.) useful for long-term production needs. Official reference: docs.percona.com/postgresql/17/yum.html.
The service structure, data paths, and binary names follow upstream PostgreSQL conventions (postgresql-17.service, /usr/pgsql-17/, /var/lib/pgsql/17/data/), so all post-installation steps (initialization, pg_hba.conf, systemd) are identical.
3.1 Install Dependencies & Percona Repository
sudo dnf -y install curl
# Install the Percona repository management toolsudo dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm
# Enable the Percona Distribution for PostgreSQL 17 repositorysudo percona-release setup ppg17
# Disable the default AppStream postgresql module to avoid conflictssudo dnf -qy module disable postgresql3.2 Install Percona PostgreSQL Server
sudo dnf install -y percona-postgresql17-server percona-postgresql17-contrib3.3 Initialize & Start the Service
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
sudo systemctl enable --now postgresql-17sudo systemctl status postgresql-17Verify that the installed build is from Percona:
psql --versionExpected output:
psql (PostgreSQL) 17.10 - Percona Server for PostgreSQL 17.10.23.4 Create Database & User for Keycloak
# ⚠️ Replace YOUR_STRONG_DB_PASSWORD with a secure passwordsudo -u postgres psql <<'EOF'CREATE DATABASE keycloak;CREATE USER kc_user WITH ENCRYPTED PASSWORD 'YOUR_STRONG_DB_PASSWORD';GRANT ALL PRIVILEGES ON DATABASE keycloak TO kc_user;ALTER DATABASE keycloak OWNER TO kc_user;EOF3.5 Enable Password Authentication (scram-sha-256)
By default, PostgreSQL on RHEL-family systems uses peer authentication for local connections. Since Keycloak connects via TCP (127.0.0.1), we need to add a password authentication entry to pg_hba.conf:
sudo tee -a /var/lib/pgsql/17/data/pg_hba.conf > /dev/null <<'EOF'host keycloak kc_user 127.0.0.1/32 scram-sha-256EOF
sudo systemctl restart postgresql-174. Install Keycloak 26.7.0
4.1 Create a Dedicated System User
sudo groupadd keycloaksudo useradd -r -g keycloak -d /opt/keycloak -s /sbin/nologin keycloak4.2 Download & Extract Keycloak
cd /tmpwget https://github.com/keycloak/keycloak/releases/download/26.7.0/keycloak-26.7.0.zip
sudo unzip keycloak-26.7.0.zip -d /opt/sudo mv /opt/keycloak-26.7.0 /opt/keycloaksudo chown -R keycloak:keycloak /opt/keycloak5. Configure keycloak.conf
Edit the Keycloak configuration file:
sudo nano /opt/keycloak/conf/keycloak.confPaste the following configuration:
# ---------- Database ----------db=postgresdb-username=kc_userdb-password=YOUR_STRONG_DB_PASSWORD # ⚠️ Use the same password from step 3.4db-url=jdbc:postgresql://127.0.0.1:5432/keycloak
# ---------- Hostname ----------hostname=your-domain.example.com # ⚠️ Replace with your actual domainhostname-strict-backchannel=true
# ---------- Proxy (behind Nginx) ----------proxy-headers=xforwardedhttp-enabled=truehttp-host=127.0.0.1http-port=8080
# ---------- Production Features ----------health-enabled=truemetrics-enabled=trueSince TLS termination is handled by Nginx in front, Keycloak only needs to listen for HTTP connections locally (127.0.0.1:8080) and is never directly exposed to the internet.
6. Build & Bootstrap Admin
6.1 Build Optimized Configuration
cd /opt/keycloaksudo -u keycloak bin/kc.sh buildExpected output:
Updating the configuration and installing your custom providers, if any. Please wait....Server configuration updated and persisted. Run the following command to review the configuration:
kc.sh show-config6.2 Create the Initial Admin Account
Since Keycloak 26.x, the --password flag on the bootstrap-admin command has been removed for security reasons (to avoid passwords being recorded in shell history or visible via ps aux). Use one of the following methods:
Method 1 — Interactive (recommended for a one-time manual setup):
# ⚠️ Replace YOUR_ADMIN_USERNAME with your desired admin usernamesudo -u keycloak bin/kc.sh bootstrap-admin user --username YOUR_ADMIN_USERNAMEYou will be prompted to type the password directly in the terminal. Choose a strong, unique password and store it securely.
Method 2 — Via Environment Variable (for automation/scripted setups):
# ⚠️ Replace the values below with your desired admin credentialsexport KC_BOOTSTRAP_ADMIN_PASSWORD='YOUR_ADMIN_PASSWORD'
sudo -u keycloak -E bin/kc.sh bootstrap-admin user \ --username YOUR_ADMIN_USERNAME \ --password:env KC_BOOTSTRAP_ADMIN_PASSWORDThe -E flag on sudo is required to pass the KC_BOOTSTRAP_ADMIN_PASSWORD environment variable to the process running as the keycloak user. After the account is created, remove the variable from your shell:
unset KC_BOOTSTRAP_ADMIN_PASSWORD7. Systemd Service
Create the unit file at /etc/systemd/system/keycloak.service:
[Unit]Description=Keycloak Identity and Access Management ServerAfter=network.target postgresql-17.serviceWants=postgresql-17.service
[Service]Type=simpleUser=keycloakGroup=keycloakWorkingDirectory=/opt/keycloakEnvironment="JAVA_HOME=/opt/jdk-25"Environment="JAVA_OPTS=-Xms512m -Xmx2048m"ExecStart=/opt/keycloak/bin/kc.sh start --optimizedExecStop=/opt/keycloak/bin/kc.sh stopRestart=on-failureRestartSec=10TimeoutStartSec=300
# Basic hardeningNoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=trueReadWritePaths=/opt/keycloak
[Install]WantedBy=multi-user.targetWhy
PrivateTmp=true? This directive is essential when used alongsideProtectSystem=strict. Without it, Keycloak (via Quarkus/Vert.x) will fail to start with aRead-only file systemerror when attempting to write temporary cache files to/tmp, becauseProtectSystem=strictlocks the entire filesystem except paths listed inReadWritePaths.PrivateTmp=truegives the service its own private, writable/tmpwithout weakening the overall hardening posture.
Enable and start the service:
sudo systemctl daemon-reloadsudo systemctl enable --now keycloaksudo systemctl status keycloakCheck logs if anything goes wrong:
sudo journalctl -u keycloak -f8. Reverse Proxy with Nginx + SSL (Let’s Encrypt)
8.1 Install Nginx & Certbot
sudo dnf install -y epel-releasesudo dnf install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx8.2 Create the Virtual Host Configuration
# ⚠️ Replace your-domain.example.com with your actual domain (in both the filename and content)sudo tee /etc/nginx/conf.d/your-domain.example.com.conf > /dev/null <<'EOF'server { listen 80; server_name your-domain.example.com;
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $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-Host $host; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; }}EOF
sudo nginx -tsudo systemctl reload nginx8.3 Issue the TLS Certificate
# ⚠️ Replace your-domain.example.com with your actual domainsudo certbot --nginx -d your-domain.example.comCertbot will automatically add the listen 443 ssl block and redirect HTTP traffic to HTTPS. Certificate renewal runs automatically via the systemd timer (certbot-renew.timer), which you can verify with:
systemctl list-timers | grep certbot9. Firewall & SELinux
9.1 Firewalld
AlmaLinux 9 runs firewalld by default, so all port management in this guide is done through firewall-cmd rather than disabling the firewall.
Check the active zone and firewalld status:
sudo systemctl status firewalldsudo firewall-cmd --get-default-zonesudo firewall-cmd --list-allOpen HTTP and HTTPS services (required for Let’s Encrypt validation and traffic to Nginx):
sudo firewall-cmd --permanent --add-service=httpsudo firewall-cmd --permanent --add-service=httpssudo firewall-cmd --reloadVerify allowed services:
sudo firewall-cmd --list-servicesPorts intentionally NOT opened in firewalld:
- 8080 (Keycloak internal) — Only bound to
127.0.0.1and accessed locally by Nginx. It must not be exposed to the public internet. - 5432 (PostgreSQL) — Only accessed locally by Keycloak. No external access is needed.
Since neither service is added with --add-port, firewalld automatically rejects all inbound traffic to these ports. You can confirm this with:
sudo firewall-cmd --list-ports# Expected: empty or not showing 8080/5432Optional — Restrict Admin Console access to specific IPs/CIDRs. If you want to limit who can access /admin (e.g., only from your office network or VPN), use a rich rule on the firewalld zone facing the server’s public IP:
# ⚠️ Replace 203.0.113.0/24 with your actual trusted network CIDRsudo firewall-cmd --permanent --zone=public --add-rich-rule=' rule family="ipv4" source address="203.0.113.0/24" service name="https" accept'
sudo firewall-cmd --permanent --zone=public --remove-service=httpssudo firewall-cmd --reloadWarning: The commands above change HTTPS from “open to all” to “only from defined source IPs.” Only use this if all Keycloak users access from networks with predictable IPs (e.g., via VPN). If the CIDR is wrong, access will be cut off for everyone, including admins.
9.2 SELinux
AlmaLinux 9 runs SELinux in enforcing mode by default. To allow Nginx to proxy to the Keycloak backend (port 8080), enable the following boolean:
sudo setsebool -P httpd_can_network_connect onIf other access denials appear in the logs, investigate with:
sudo ausearch -m avc -ts recent10. Verification
Open https://your-domain.example.com/admin/ in your browser and log in using the admin account created in step 6.2.

After a successful login, you should see the Keycloak Administration Console:

You can also verify the health endpoint (if health-enabled=true):
curl -s http://127.0.0.1:8080/health/readyExpected response:
{"status": "UP", "checks": []}Troubleshooting
| Symptom | Likely Cause |
|---|---|
systemctl status keycloak shows failure | Check journalctl -u keycloak -f; often caused by database connection not ready or port conflict |
password authentication failed database error | Verify pg_hba.conf entries and make sure the password in keycloak.conf matches the one set in step 3.4 |
| Nginx 502 Bad Gateway | Keycloak is not listening on 127.0.0.1:8080; check http-host/http-port in keycloak.conf and the Keycloak service status |
| Redirect loop or “invalid redirect_uri” on client login | The redirect URI configured in Keycloak does not exactly match the one sent by the application (watch for trailing slashes) |
| Let’s Encrypt certificate fails to issue | Ensure DNS for the domain has propagated and port 80 is open when certbot runs |
| Nginx cannot proxy despite correct configuration | Likely blocked by SELinux; run setsebool -P httpd_can_network_connect on |
Summary: Update system → Install OpenJDK 25 manually → Install Percona Distribution for PostgreSQL 17 → Download & build Keycloak 26.7.0 → Run as a systemd service → Expose via Nginx with a Let’s Encrypt certificate → Manage port access with firewall-cmd and adjust SELinux policies.