End of ClickOps: Keycloak Configuration as Code
Identity and Access Management (IAM) is the new perimeter. Yet, many organizations still manage their Keycloak instances via “ClickOps”—manually clicking through the admin console. This leads to configuration drift, undocumented changes, and the terrifying possibility of accidentally disabling authentication in production.
This guide provides a walkthrough for managing Keycloak using the Terraform provider. We will build a minimal, concrete example including:
- Provider Setup: Pinning versions for stability.
- Infrastructure: One Realm, one OIDC Client, and one Identity Provider.
- Automation: A GitHub Actions pipeline for CI/CD.
- Drift Detection: How to catch manual changes before they cause issues.
Prerequisites
Before diving in, ensure you have:
- A running Keycloak instance (v16+ or Quarkus distribution).
- A dedicated “Automation” Client in the
masterrealm withrealm-managementroles. Avoid using the admin user for Terraform; use a service account. - A remote state backend (S3, Azure Blob, or Terraform Cloud) to store your
terraform.tfstate.
1. The Provider Configuration
We start by defining our provider. We pin the version to ensure that breaking changes in the provider don’t break our pipeline unexpectedly.
providers.tf
terraform {
required_providers {
keycloak = {
source = "keycloak/keycloak" # or mrparkers/keycloak for older setups
version = ">= 5.0.0"
}
}
}
provider "keycloak" {
url = var.keycloak_url # e.g. https://auth.example.com
realm = "master" # Admin realm for API auth
client_id = var.automation_client_id
client_secret = var.automation_client_secret
# specific_scopes = ["openid", "microprofile-jwt"] # Optional: strictly define scopes
}
variable "keycloak_url" {}
variable "automation_client_id" {}
variable "automation_client_secret" { sensitive = true }
2. Note on State Management
Crucial Step: If you are running this in CI (GitHub Actions), you cannot store the state file locally. You must configure a remote backend.
backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "keycloak/prod.tfstate"
region = "us-east-1"
}
}
3. The Implementation
Here is a working configuration for a standard application setup.
A. The Realm
The realm is the top-level container for your users and clients.
resource "keycloak_realm" "app" {
realm = "app"
display_name = "App Realm"
enabled = true
# Security Best Practice: Strict HTTPS
ssl_required = "external"
# Recommended: Configure SMTP here so Keycloak can send 'Forgot Password' emails
smtp_server {
host = "smtp.sendgrid.net"
port = "587"
from = "no-reply@example.com"
auth {
user = var.smtp_user
password = var.smtp_password
}
}
}
B. The Public Client
This represents your frontend web application (React, Vue, etc.).
Security Tip: We explicitly disable direct_access_grants_enabled. This prevents the “Resource Owner Password Credentials Grant,” ensuring users must log in via the browser (Standard Flow), which enables MFA support.
variable "web_redirect_uris" {
type = list(string)
default = ["https://app.example.com/*"]
}
resource "keycloak_openid_client" "web" {
realm_id = keycloak_realm.app.id
client_id = "web"
name = "Web App frontend"
access_type = "PUBLIC" # No client secret required
standard_flow_enabled = true # Authorization Code Flow
implicit_flow_enabled = false # Obsolete/Insecure
direct_access_grants_enabled= false # Disable password grant!
valid_redirect_uris = var.web_redirect_uris
web_origins = ["+"] # CORS: Matches redirect URIs
root_url = "https://app.example.com"
}
C. The Identity Provider (IdP)
Instead of managing passwords in Keycloak, we often federate to an upstream provider (Corporate Azure AD, Okta, or Google).
resource "keycloak_oidc_identity_provider" "corp" {
realm = keycloak_realm.app.id
alias = "corp-oidc"
display_name = "Corporate Login"
# Endpoints for the upstream IdP
authorization_url = "https://login.corp.example.com/oauth2/v1/authorize"
token_url = "https://login.corp.example.com/oauth2/v1/token"
user_info_url = "https://login.corp.example.com/oauth2/v1/userinfo"
jwks_url = "https://login.corp.example.com/oauth2/v1/keys"
client_id = var.corp_oidc_client_id
client_secret = var.corp_oidc_client_secret
# Sync mode for importing user data (Force, Import, Legacy)
sync_mode = "IMPORT"
store_token = false
}
# Automatically map the email from the upstream IdP to the Keycloak User
resource "keycloak_attribute_importer_identity_provider_mapper" "corp_email" {
realm = keycloak_realm.app.id
name = "email-importer"
identity_provider_alias = keycloak_oidc_identity_provider.corp.alias
user_attribute = "email"
claim_name = "email"
}
variable "corp_oidc_client_id" {}
variable "corp_oidc_client_secret" { sensitive = true }
4. Continuous Deployment (CI)
This GitHub Action validates your configuration on Pull Requests and applies it when merged to main.
.github/workflows/keycloak.yml
name: Keycloak CI/CD
on:
pull_request:
push:
branches: ['main']
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.6
- name: Terraform Init
run: terraform init
- name: Terraform Plan
if: github.event_name == 'pull_request'
env:
# Map GitHub Secrets to TF Variables
TF_VAR_keycloak_url: ${{ secrets.KC_URL }}
TF_VAR_automation_client_id: ${{ secrets.KC_CLIENT_ID }}
TF_VAR_automation_client_secret: ${{ secrets.KC_CLIENT_SECRET }}
TF_VAR_corp_oidc_client_id: ${{ secrets.CORP_OIDC_CLIENT_ID }}
TF_VAR_corp_oidc_client_secret: ${{ secrets.CORP_OIDC_CLIENT_SECRET }}
# AWS/Backend creds if using S3 state
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: terraform plan -no-color
- name: Terraform Apply
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
TF_VAR_keycloak_url: ${{ secrets.KC_URL }}
TF_VAR_automation_client_id: ${{ secrets.KC_CLIENT_ID }}
TF_VAR_automation_client_secret: ${{ secrets.KC_CLIENT_SECRET }}
TF_VAR_corp_oidc_client_id: ${{ secrets.CORP_OIDC_CLIENT_ID }}
TF_VAR_corp_oidc_client_secret: ${{ secrets.CORP_OIDC_CLIENT_SECRET }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: terraform apply -auto-approve
5. Drift Detection: Sleeping Soundly
Drift occurs when an admin changes a setting manually in the GUI, bypassing Terraform. We need to detect this to maintain integrity.
Create a specific scheduled workflow. The magic flag here is -detailed-exitcode. This forces Terraform to return an error code (2) if there are pending changes, which fails the GitHub Action and sends you a notification email.
.github/workflows/drift.yml
name: Keycloak Drift Detection
on:
schedule:
- cron: '0 8 * * *' # Every morning at 8am UTC
jobs:
check-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- name: Check for Drift
id: plan
env:
TF_VAR_keycloak_url: ${{ secrets.KC_URL }}
TF_VAR_automation_client_id: ${{ secrets.KC_CLIENT_ID }}
TF_VAR_automation_client_secret: ${{ secrets.KC_CLIENT_SECRET }}
# ... other secrets
# -detailed-exitcode returns 0 for no changes, 1 for error, 2 for changes present
run: terraform plan -detailed-exitcode -refresh-only
Guardrails & Conclusion
Adopting Terraform for Keycloak transforms your IAM from a fragile “pet” into managed “cattle.” To succeed, keep these tips in mind:
- Least Privilege: Give your CI Automation Client only the scopes it needs. Do not make it a super-admin.
- Immutable Identifiers: Avoid renaming Realms or Clients once created.
- Secrets Management: Never commit Client Secrets to Git. Use GitHub Actions Secrets or a HashiCorp Vault integration.
- Database Backups: Terraform manages configuration, not data. It will not save your user base if the database corrupts. Ensure you have SQL-level backups.