Configure Keycloak custom themes and branding with SSL integration and production deployment

Intermediate 45 min Apr 20, 2026 141 views
Ubuntu 24.04 Debian 12 AlmaLinux 9 Rocky Linux 9

Set up custom Keycloak themes with your organization's branding, implement SSL certificates, and deploy to production with hot reload development workflow and performance optimization.

Prerequisites

  • Keycloak installation
  • Node.js and npm
  • NGINX web server
  • Basic CSS/JavaScript knowledge

What this solves

Keycloak's default theme works for testing, but production deployments need custom branding that matches your organization's identity. This tutorial shows you how to create custom themes, configure SSL certificates, set up development workflows with hot reload, and deploy everything to production with proper security hardening.

Step-by-step configuration

Install theme development dependencies

Install Node.js and development tools needed for theme customization and asset compilation.

sudo apt update
sudo apt install -y nodejs npm git curl unzip
node --version
npm --version
sudo dnf update -y
sudo dnf install -y nodejs npm git curl unzip
node --version
npm --version

Create custom theme directory structure

Set up the theme directory structure in your Keycloak installation. This follows Keycloak's theme provider architecture.

sudo mkdir -p /opt/keycloak/themes/custom-brand
sudo mkdir -p /opt/keycloak/themes/custom-brand/login
sudo mkdir -p /opt/keycloak/themes/custom-brand/account
sudo mkdir -p /opt/keycloak/themes/custom-brand/admin
sudo mkdir -p /opt/keycloak/themes/custom-brand/email
sudo mkdir -p /opt/keycloak/themes/custom-brand/welcome
sudo chown -R keycloak:keycloak /opt/keycloak/themes/custom-brand

Configure theme properties

Create the main theme configuration file that defines the theme structure and inheritance.

parent=keycloak
import=common/keycloak

styles=css/login.css css/custom.css
scripts=js/custom.js

locales=en,de,fr,es

Create login theme resources

Set up the login page theme with custom CSS and template overrides.

parent=base
import=common/keycloak

styles=css/login.css
scripts=js/login.js

locales=en,de,fr,es

Create CSS directory and custom styles

Add your organization's branding with custom CSS that overrides the default Keycloak styles.

sudo mkdir -p /opt/keycloak/themes/custom-brand/login/resources/css
sudo mkdir -p /opt/keycloak/themes/custom-brand/login/resources/js
sudo mkdir -p /opt/keycloak/themes/custom-brand/login/resources/img
/ Custom branding styles /
:root {
  --brand-primary: #2563eb;
  --brand-secondary: #1e40af;
  --brand-accent: #3b82f6;
  --text-color: #1f2937;
  --background: #f9fafb;
}

.login-pf body {
  background: var(--background);
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}

.login-pf-page .card-pf {
  background: white;
  border-radius: 12px;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
  border: none;
}

#kc-header-wrapper {
  background: var(--brand-primary);
  padding: 2rem 0;
}

#kc-header-wrapper #kc-header {
  color: white;
  font-size: 1.5rem;
  font-weight: 600;
}

.btn-primary {
  background-color: var(--brand-primary);
  border-color: var(--brand-primary);
  border-radius: 8px;
  padding: 0.75rem 1.5rem;
  font-weight: 500;
  transition: all 0.2s;
}

.btn-primary:hover {
  background-color: var(--brand-secondary);
  border-color: var(--brand-secondary);
  transform: translateY(-1px);
}

.form-control {
  border-radius: 6px;
  border: 1.5px solid #d1d5db;
  padding: 0.75rem;
  transition: border-color 0.2s;
}

.form-control:focus {
  border-color: var(--brand-primary);
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}

#kc-logo {
  width: 180px;
  height: auto;
}

.alert-error {
  background-color: #fef2f2;
  border-color: #fecaca;
  color: #dc2626;
  border-radius: 8px;
}

.alert-success {
  background-color: #f0fdf4;
  border-color: #bbf7d0;
  color: #16a34a;
  border-radius: 8px;
}

Add custom JavaScript functionality

Create JavaScript enhancements for better user experience and form validation.

// Custom login page enhancements
document.addEventListener('DOMContentLoaded', function() {
    // Add loading state to login button
    const loginForm = document.getElementById('kc-form-login');
    const loginButton = document.getElementById('kc-login');
    
    if (loginForm && loginButton) {
        loginForm.addEventListener('submit', function() {
            loginButton.disabled = true;
            loginButton.innerHTML = ' Signing in...';
        });
    }
    
    // Enhanced form validation
    const usernameField = document.getElementById('username');
    const passwordField = document.getElementById('password');
    
    function validateField(field, minLength = 1) {
        if (field.value.length < minLength) {
            field.classList.add('error');
            return false;
        } else {
            field.classList.remove('error');
            return true;
        }
    }
    
    if (usernameField) {
        usernameField.addEventListener('blur', function() {
            validateField(this);
        });
    }
    
    if (passwordField) {
        passwordField.addEventListener('blur', function() {
            validateField(this, 1);
        });
    }
    
    // Add custom analytics tracking
    function trackEvent(event, data) {
        if (typeof gtag !== 'undefined') {
            gtag('event', event, data);
        }
    }
    
    // Track login attempts
    if (loginForm) {
        loginForm.addEventListener('submit', function() {
            trackEvent('login_attempt', {
                'event_category': 'authentication',
                'event_label': 'keycloak_login'
            });
        });
    }
});

Create custom login template

Override the default login template with your custom branding and layout modifications.

<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
    <#if section = "header">
        <div id="kc-header-wrapper">
            <div id="kc-header">
                <#if realm.displayNameHtml??>
                    ${realm.displayNameHtml}
                <#else>
                    ${realm.displayName!"Sign In"}
                </#if>
            </div>
        </div>
    <#elseif section = "form">
    <div id="kc-form">
      <div id="kc-form-wrapper">
        <#if realm.password>
            <form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
                <div class="${properties.kcFormGroupClass!}">
                    <label for="username" class="${properties.kcLabelClass!}">
                        <#if !realm.loginWithEmailAllowed>${msg("username")}
                        <#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}
                        <#else>${msg("email")}
                        </#if>
                    </label>

                    <#if usernameEditDisabled??>
                        <input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')" type="text" disabled />
                    <#else>
                        <input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')"  type="text" autofocus autocomplete="off"
                               aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
                        />

                        <#if messagesPerField.existsError('username','password')>
                            <span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
                                        ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
                            </span>
                        </#if>
                    </#if>
                </div>

                <div class="${properties.kcFormGroupClass!}">
                    <label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>

                    <input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
                           aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
                    />
                </div>

                <div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
                    <div id="kc-form-options">
                        <#if realm.rememberMe && !usernameEditDisabled??>
                            <div class="checkbox">
                                <label>
                                    <#if login.rememberMe??>
                                        <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
                                    <#else>
                                        <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
                                    </#if>
                                </label>
                            </div>
                        </#if>
                        </div>
                        <div class="${properties.kcFormOptionsWrapperClass!}">
                            <#if realm.resetPasswordAllowed>
                                <span><a tabindex="5" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
                            </#if>
                        </div>

                  </div>

                  <div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
                      <input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
                      <input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
                  </div>
            </form>
        </#if>
        </div>
    </div>
    <#elseif section = "info">
        <#if realm.password && realm.registrationAllowed && !registrationDisabled??>
            <div id="kc-registration-container">
                <div id="kc-registration">
                    <span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
                </div>
            </div>
        </#if>
    </#if>

</@layout.registrationLayout>

Configure theme hot reload for development

Set up Keycloak for development mode with theme caching disabled for faster iteration.

# Development configuration

Database

db=postgres db-username=keycloak db-password=your-secure-password db-url=jdbc:postgresql://localhost:5432/keycloak

HTTP/HTTPS

http-enabled=true http-port=8080 https-port=8443 https-certificate-file=/opt/keycloak/certs/server.crt https-certificate-key-file=/opt/keycloak/certs/server.key

Hostname

hostname=example.com hostname-strict=false hostname-strict-https=false

Cache (disable for development)

cache=local cache-stack=local theme-cache-themes=false theme-cache-templates=false theme-static-max-age=-1

Proxy

proxy=edge proxy-headers=forwarded|x-forwarded-for|x-forwarded-proto|x-forwarded-host

Logging

log-level=INFO log-console-color=true

Generate SSL certificates

Create SSL certificates for secure HTTPS connections. Use Let's Encrypt for production or self-signed for development.

sudo mkdir -p /opt/keycloak/certs
cd /opt/keycloak/certs

Generate self-signed certificate for development

sudo openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes -subj "/C=US/ST=State/L=City/O=Organization/OU=IT/CN=example.com"

Set proper permissions

sudo chown keycloak:keycloak /opt/keycloak/certs/* sudo chmod 600 /opt/keycloak/certs/server.key sudo chmod 644 /opt/keycloak/certs/server.crt
Production SSL: For production environments, use Let's Encrypt certificates instead of self-signed ones. Install certbot and obtain valid certificates for your domain.

Configure reverse proxy integration

Set up NGINX reverse proxy to handle SSL termination and serve static theme assets efficiently.

server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;
    
    # SSL Configuration
    ssl_certificate /etc/ssl/certs/keycloak.crt;
    ssl_certificate_key /etc/ssl/private/keycloak.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    
    # Security Headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
    
    # Theme assets caching
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        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;
        
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    # Main proxy configuration
    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-Port $server_port;
        
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;
        
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

Enable the NGINX configuration

Activate the Keycloak site configuration and restart NGINX.

sudo ln -s /etc/nginx/sites-available/keycloak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Configure theme in Keycloak admin console

Apply your custom theme to realms through the administrative interface.

# Start Keycloak
sudo systemctl start keycloak
sudo systemctl enable keycloak

Check status

sudo systemctl status keycloak

Set up production deployment configuration

Configure Keycloak for production with optimized settings and security hardening.

# Production configuration

Database

db=postgres db-username=keycloak db-password=${KC_DB_PASSWORD} db-url=jdbc:postgresql://localhost:5432/keycloak db-pool-initial-size=10 db-pool-min-size=10 db-pool-max-size=50

HTTPS only

http-enabled=false https-port=8443 https-certificate-file=/opt/keycloak/certs/server.crt https-certificate-key-file=/opt/keycloak/certs/server.key

Hostname

hostname=example.com hostname-strict=true hostname-strict-https=true

Cache (enable for production)

cache=ispn cache-stack=kubernetes theme-cache-themes=true theme-cache-templates=true theme-static-max-age=31536000

Proxy

proxy=edge proxy-headers=forwarded|x-forwarded-for|x-forwarded-proto|x-forwarded-host

Logging

log-level=WARN log-file=/var/log/keycloak/keycloak.log log-file-max-size=10MB log-file-max-backup-index=10

Performance

spi-theme-static-max-age=31536000 spi-theme-cache-themes=true spi-theme-cache-templates=true spi-login-protocol-openid-connect-legacy-logout-redirect-uri=true

Security

spi-truststore-file-file=/opt/keycloak/truststore.p12 spi-truststore-file-password=${KC_TRUSTSTORE_PASSWORD} spi-truststore-file-hostname-verification-policy=WILDCARD

Create theme deployment script

Automate theme deployment with a script that handles building, testing, and deployment.

#!/bin/bash

Keycloak theme deployment script

set -euo pipefail THEME_NAME="custom-brand" THEME_DIR="/opt/keycloak/themes/${THEME_NAME}" BACKUP_DIR="/opt/keycloak/backups/themes" KEYCLOAK_SERVICE="keycloak" echo "Starting theme deployment for ${THEME_NAME}..."

Create backup

echo "Creating backup..." sudo mkdir -p "${BACKUP_DIR}" if [ -d "${THEME_DIR}" ]; then sudo cp -r "${THEME_DIR}" "${BACKUP_DIR}/${THEME_NAME}-$(date +%Y%m%d-%H%M%S)" fi

Validate CSS and JS

echo "Validating theme assets..." find "${THEME_DIR}" -name "*.css" -exec csslint {} \; find "${THEME_DIR}" -name "*.js" -exec jshint {} \;

Set proper permissions

echo "Setting permissions..." sudo chown -R keycloak:keycloak "${THEME_DIR}" sudo find "${THEME_DIR}" -type f -exec chmod 644 {} \; sudo find "${THEME_DIR}" -type d -exec chmod 755 {} \;

Restart Keycloak if running

if sudo systemctl is-active --quiet "${KEYCLOAK_SERVICE}"; then echo "Restarting Keycloak service..." sudo systemctl restart "${KEYCLOAK_SERVICE}" # Wait for service to be ready echo "Waiting for Keycloak to be ready..." timeout 120 bash -c 'until curl -f -s -k https://localhost:8443/health/ready; do sleep 2; done' fi echo "Theme deployment completed successfully!" echo "Please update realm theme settings in the admin console."

Make deployment script executable

Set proper permissions and test the deployment script.

sudo chmod +x /opt/keycloak/scripts/deploy-theme.sh
sudo mkdir -p /opt/keycloak/backups/themes

Test the deployment

sudo /opt/keycloak/scripts/deploy-theme.sh

Verify your setup

Test your custom theme configuration and SSL setup with these verification commands.

# Check Keycloak service status
sudo systemctl status keycloak

Verify SSL configuration

openssl s_client -connect example.com:8443 -servername example.com

Test theme endpoints

curl -k -I https://example.com:8443/realms/master/login-actions/authenticate curl -k -I https://example.com:8443/resources/custom-brand/login/css/login.css

Check logs for theme loading

sudo tail -f /var/log/keycloak/keycloak.log | grep -i theme

Verify NGINX proxy

curl -I https://example.com/auth/realms/master/.well-known/openid_configuration

Common issues

SymptomCauseFix
Theme not loadingIncorrect file permissionssudo chown -R keycloak:keycloak /opt/keycloak/themes/
CSS changes not visibleTheme caching enabledSet theme-cache-themes=false in development
SSL certificate errorsWrong certificate pathVerify paths in keycloak.conf and file permissions
502 Bad GatewayKeycloak not runningsudo systemctl start keycloak
Template parsing errorsInvalid FreeMarker syntaxCheck template syntax and escape special characters
Asset 404 errorsMissing static filesVerify theme directory structure and file names

Next steps

Running this in production?

Want this handled for you? Setting up custom themes once is straightforward. Keeping them updated, optimized, and secure across environments while managing SSL certificates and monitoring performance is the harder part. See how we run infrastructure like this for European teams building identity platforms.

Automated install script

Run this to automate the entire setup

Need help?

Don't want to manage this yourself?

We handle infrastructure security hardening for businesses that depend on uptime. From initial setup to ongoing operations.