> Article

Managing Keycloak Configuration as Code with Terraform

By Bearloggs
#devsecops #keycloak #oidc #iam #terraform

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:

  1. Provider Setup: Pinning versions for stability.
  2. Infrastructure: One Realm, one OIDC Client, and one Identity Provider.
  3. Automation: A GitHub Actions pipeline for CI/CD.
  4. 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 master realm with realm-management roles. 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:

  1. Least Privilege: Give your CI Automation Client only the scopes it needs. Do not make it a super-admin.
  2. Immutable Identifiers: Avoid renaming Realms or Clients once created.
  3. Secrets Management: Never commit Client Secrets to Git. Use GitHub Actions Secrets or a HashiCorp Vault integration.
  4. Database Backups: Terraform manages configuration, not data. It will not save your user base if the database corrupts. Ensure you have SQL-level backups.

Further Resources#