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

Intermediate 45 min Apr 20, 2026 653 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 = '<i></i> 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 t

Automated install script

Run this to automate the entire setup

Sie möchten das nicht selbst verwalten?

Wir betreiben Infrastruktur für Unternehmen, die auf Verfügbarkeit angewiesen sind. Vollständig verwaltet, mit einem festen Ansprechpartner, der Ihre Umgebung kennt.

Sie erhalten einen festen Ansprechpartner, der Ihr Setup kennt

Am Schreibtisch in Rotterdam 14:56 · erreichbar per Nachricht, kein Ticketformular