1661 words
8 minutes
Deploying Keycloak 26 in Production on AlmaLinux 9 with PostgreSQL, Nginx & Let's Encrypt

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, or YOUR_ADMIN_PASSWORD, replace them with your own. A summary of all placeholders is provided below.

PlaceholderDescriptionExample
your-domain.example.comYour Keycloak FQDN (must have a DNS A record pointing to your server’s public IP)sso.example.com
YOUR_STRONG_DB_PASSWORDPassword for the PostgreSQL kc_user database userUse a password manager to generate one
YOUR_ADMIN_USERNAMEKeycloak admin console usernameadmin
YOUR_ADMIN_PASSWORDKeycloak admin console passwordUse 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 sudo privileges.
  • 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#

ComponentVersion
OSAlmaLinux 9
JavaOpenJDK 25
DatabasePostgreSQL 17 (Percona Distribution)
Keycloak26.7.0
Web ServerNginx (reverse proxy)
SSLLet’s Encrypt (Certbot)

1. System Preparation#

Update existing packages and install basic utilities:

Terminal window
sudo dnf update -y
sudo dnf install -y wget curl unzip tar policycoreutils-python-utils

Optionally, set the hostname for log and certificate consistency:

Terminal window
# Replace with your actual domain
sudo hostnamectl set-hostname your-domain.example.com

2. 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#

Terminal window
cd /opt
sudo 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.gz
sudo mv jdk-25.0.2 /opt/jdk-25
sudo rm openjdk-25.0.2_linux-x64_bin.tar.gz

Tip: 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#

Terminal window
sudo alternatives --install /usr/bin/java java /opt/jdk-25/bin/java 2500
sudo alternatives --install /usr/bin/javac javac /opt/jdk-25/bin/javac 2500
sudo alternatives --set java /opt/jdk-25/bin/java
sudo alternatives --set javac /opt/jdk-25/bin/javac

2.3 Set JAVA_HOME System-Wide#

Terminal window
sudo tee /etc/profile.d/java25.sh > /dev/null <<'EOF'
export JAVA_HOME=/opt/jdk-25
export PATH=$JAVA_HOME/bin:$PATH
EOF
source /etc/profile.d/java25.sh

2.4 Verify#

Terminal window
java --version

Expected output:

openjdk 25.0.2 2026-01-20
OpenJDK 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#

Terminal window
sudo dnf -y install curl
# Install the Percona repository management tool
sudo dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm
# Enable the Percona Distribution for PostgreSQL 17 repository
sudo percona-release setup ppg17
# Disable the default AppStream postgresql module to avoid conflicts
sudo dnf -qy module disable postgresql

3.2 Install Percona PostgreSQL Server#

Terminal window
sudo dnf install -y percona-postgresql17-server percona-postgresql17-contrib

3.3 Initialize & Start the Service#

Terminal window
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
sudo systemctl enable --now postgresql-17
sudo systemctl status postgresql-17

Verify that the installed build is from Percona:

Terminal window
psql --version

Expected output:

psql (PostgreSQL) 17.10 - Percona Server for PostgreSQL 17.10.2

3.4 Create Database & User for Keycloak#

Terminal window
# ⚠️ Replace YOUR_STRONG_DB_PASSWORD with a secure password
sudo -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;
EOF

3.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:

Terminal window
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-256
EOF
sudo systemctl restart postgresql-17

4. Install Keycloak 26.7.0#

4.1 Create a Dedicated System User#

Terminal window
sudo groupadd keycloak
sudo useradd -r -g keycloak -d /opt/keycloak -s /sbin/nologin keycloak

4.2 Download & Extract Keycloak#

Terminal window
cd /tmp
wget 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/keycloak
sudo chown -R keycloak:keycloak /opt/keycloak

5. Configure keycloak.conf#

Edit the Keycloak configuration file:

Terminal window
sudo nano /opt/keycloak/conf/keycloak.conf

Paste the following configuration:

# ---------- Database ----------
db=postgres
db-username=kc_user
db-password=YOUR_STRONG_DB_PASSWORD # ⚠️ Use the same password from step 3.4
db-url=jdbc:postgresql://127.0.0.1:5432/keycloak
# ---------- Hostname ----------
hostname=your-domain.example.com # ⚠️ Replace with your actual domain
hostname-strict-backchannel=true
# ---------- Proxy (behind Nginx) ----------
proxy-headers=xforwarded
http-enabled=true
http-host=127.0.0.1
http-port=8080
# ---------- Production Features ----------
health-enabled=true
metrics-enabled=true

Since 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#

Terminal window
cd /opt/keycloak
sudo -u keycloak bin/kc.sh build

Expected 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-config

6.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):

Terminal window
# ⚠️ Replace YOUR_ADMIN_USERNAME with your desired admin username
sudo -u keycloak bin/kc.sh bootstrap-admin user --username YOUR_ADMIN_USERNAME

You 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):

Terminal window
# ⚠️ Replace the values below with your desired admin credentials
export 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_PASSWORD

The -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:

Terminal window
unset KC_BOOTSTRAP_ADMIN_PASSWORD

7. Systemd Service#

Create the unit file at /etc/systemd/system/keycloak.service:

[Unit]
Description=Keycloak Identity and Access Management Server
After=network.target postgresql-17.service
Wants=postgresql-17.service
[Service]
Type=simple
User=keycloak
Group=keycloak
WorkingDirectory=/opt/keycloak
Environment="JAVA_HOME=/opt/jdk-25"
Environment="JAVA_OPTS=-Xms512m -Xmx2048m"
ExecStart=/opt/keycloak/bin/kc.sh start --optimized
ExecStop=/opt/keycloak/bin/kc.sh stop
Restart=on-failure
RestartSec=10
TimeoutStartSec=300
# Basic hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/keycloak
[Install]
WantedBy=multi-user.target

Why PrivateTmp=true? This directive is essential when used alongside ProtectSystem=strict. Without it, Keycloak (via Quarkus/Vert.x) will fail to start with a Read-only file system error when attempting to write temporary cache files to /tmp, because ProtectSystem=strict locks the entire filesystem except paths listed in ReadWritePaths. PrivateTmp=true gives the service its own private, writable /tmp without weakening the overall hardening posture.

Enable and start the service:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now keycloak
sudo systemctl status keycloak

Check logs if anything goes wrong:

Terminal window
sudo journalctl -u keycloak -f

8. Reverse Proxy with Nginx + SSL (Let’s Encrypt)#

8.1 Install Nginx & Certbot#

Terminal window
sudo dnf install -y epel-release
sudo dnf install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx

8.2 Create the Virtual Host Configuration#

Terminal window
# ⚠️ 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 -t
sudo systemctl reload nginx

8.3 Issue the TLS Certificate#

Terminal window
# ⚠️ Replace your-domain.example.com with your actual domain
sudo certbot --nginx -d your-domain.example.com

Certbot 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:

Terminal window
systemctl list-timers | grep certbot

9. 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:

Terminal window
sudo systemctl status firewalld
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --list-all

Open HTTP and HTTPS services (required for Let’s Encrypt validation and traffic to Nginx):

Terminal window
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Verify allowed services:

Terminal window
sudo firewall-cmd --list-services

Ports intentionally NOT opened in firewalld:

  • 8080 (Keycloak internal) — Only bound to 127.0.0.1 and 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:

Terminal window
sudo firewall-cmd --list-ports
# Expected: empty or not showing 8080/5432

Optional — 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:

Terminal window
# ⚠️ Replace 203.0.113.0/24 with your actual trusted network CIDR
sudo 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=https
sudo firewall-cmd --reload

Warning: 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:

Terminal window
sudo setsebool -P httpd_can_network_connect on

If other access denials appear in the logs, investigate with:

Terminal window
sudo ausearch -m avc -ts recent

10. Verification#

Open https://your-domain.example.com/admin/ in your browser and log in using the admin account created in step 6.2.

Keycloak Login Page

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

Keycloak Administration Console

You can also verify the health endpoint (if health-enabled=true):

Terminal window
curl -s http://127.0.0.1:8080/health/ready

Expected response:

{"status": "UP", "checks": []}

Troubleshooting#

SymptomLikely Cause
systemctl status keycloak shows failureCheck journalctl -u keycloak -f; often caused by database connection not ready or port conflict
password authentication failed database errorVerify pg_hba.conf entries and make sure the password in keycloak.conf matches the one set in step 3.4
Nginx 502 Bad GatewayKeycloak 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 loginThe 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 issueEnsure DNS for the domain has propagated and port 80 is open when certbot runs
Nginx cannot proxy despite correct configurationLikely 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.

Deploying Keycloak 26 in Production on AlmaLinux 9 with PostgreSQL, Nginx & Let's Encrypt
https://im-gatan.com/posts/deploy-keycloak-almalinux9/
Author
Gatan
Published at
2026-07-13
License
CC BY-NC-SA 4.0