Keycloak configuration as code with Terraform

By Bearloggs
devsecops keycloak oidc iam

Keycloak configuration as code with Terraform#

Walkthrough for managing Keycloak using the Terraform provider.

It includes:

  • Provider pinning and CI
  • Drift detection
  • A minimal, concrete example: one realm, one OpenID Connect client, and one OIDC Identity Provider

Use this as a starting point and expand as your realm grows.

Prerequisites#

  • A running Keycloak instance.
  • A dedicated “automation” client in the admin realm (usually master) with the minimal realm‑management roles needed for your target realm(s).
  • A Git repo for Terraform, and CI secrets for:
    • KC_URL, KC_CLIENT_ID, KC_CLIENT_SECRET

Provider#

providers.tf

terraform {
  required_providers {
    keycloak = {
      source  = "keycloak/keycloak"
      version = ">= 5.0.0"
    }
  }
}

provider "keycloak" {
  url           = var.keycloak_url              # e.g. https://sso.example.com
  realm         = "master"                      # admin realm for API auth
  client_id     = var.automation_client_id
  client_secret = var.automation_client_secret
  # tls_insecure_skip_verify = false            # keep TLS strict in prod
}

variable "keycloak_url" {}
variable "automation_client_id" {}
variable "automation_client_secret" { sensitive = true }

Minimal example: one realm, one client, one IdP#

Below is a working trio you can paste into main.tf. It creates:

  • Realm app
  • Public web client web with standard code flow
  • OIDC Identity Provider corp-oidc (generic OIDC, bring your real URLs)
  • A simple mapper importing the email claim from the IdP

main.tf

############################
# Realm
############################
resource "keycloak_realm" "app" {
  realm        = "app"
  display_name = "App Realm"
  enabled      = true
}

############################
# Public OIDC client
############################
variable "web_redirect_uris" {
  description = "Allowed redirect URIs for the web client"
  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"
  access_type                 = "PUBLIC"   # web app without client secret
  standard_flow_enabled       = true       # authorization code flow
  implicit_flow_enabled       = false
  direct_access_grants_enabled= false      # disable password grant for web clients
  valid_redirect_uris         = var.web_redirect_uris
  web_origins                 = ["+"]      # matches redirect URIs (or list exact origins)
  root_url                    = "https://app.example.com"
}

############################
# OIDC Identity Provider
############################
# Replace the URLs below with your IdP’s authorization, token, and userinfo endpoints.
# For example, your corporate IdP or a provider like Authentik, Okta, Azure AD (via OIDC),
# another Keycloak, etc.
resource "keycloak_oidc_identity_provider" "corp" {
  realm             = keycloak_realm.app.id
  alias             = "corp-oidc"
  display_name      = "Corporate OIDC"
  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
  default_scopes    = "openid profile email"

  backchannel_supported = true
  enabled               = true
  store_token           = true
  trust_email           = true
}

variable "corp_oidc_client_id" {}
variable "corp_oidc_client_secret" { sensitive = true }

############################
# Identity Provider Mapper: import email claim to user attribute
############################
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"

  # Keycloak 10+ expects syncMode in extra_config
  extra_config = {
    syncMode = "INHERIT"
  }
}

GitHub Actions#

.github/workflows/keycloak.yml

name: keycloak
on:
  pull_request:
  push:
    branches: [ "main" ]

jobs:
  terraform:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.6.6
      - name: Terraform Init
        run: terraform init -input=false
      - name: Terraform Validate
        run: terraform validate
      - name: Terraform Plan (PR)
        if: github.event_name == 'pull_request'
        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 }}
        run: terraform plan -no-color
      - name: Terraform Apply (main)
        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 }}
        run: terraform apply -auto-approve

Add these GitHub Secrets:

  • KC_URL, KC_CLIENT_ID, KC_CLIENT_SECRET
  • CORP_OIDC_CLIENT_ID, CORP_OIDC_CLIENT_SECRET

Drift detection#

  • Drift = any difference between what’s in Terraform vs. what exists live (e.g., someone changed a redirect URI in the console).
  • Detect it by running terraform plan on the current main branch regularly. If plan shows changes without a corresponding PR, you’ve got drift.

Example scheduled workflow:

name: keycloak-drift
on:
  schedule: [{ cron: "17 4 * * *" }]  # daily at 04:17 UTC
  workflow_dispatch: {}
jobs:
  drift-plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -input=false
      - name: Terraform 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 }}
          TF_VAR_corp_oidc_client_id: ${{ secrets.CORP_OIDC_CLIENT_ID }}
          TF_VAR_corp_oidc_client_secret: ${{ secrets.CORP_OIDC_CLIENT_SECRET }}
        run: terraform plan -no-color

Enhancements

  • Fail the job if the plan contains “- destroy”.
  • Post the plan diff as a PR comment or a GitHub issue when non‑empty.

Guardrails and tips#

  • Least privilege: grant your automation client only the realm‑management roles needed.
  • Naming stability: avoid renaming clients/roles/IdPs; prefer additive changes.
  • Redirect URIs and web_origins: review changes carefully—these often cause login breakages.
  • Secrets: never commit secrets; store in CI secrets or a vault action.
  • Version pinning: update provider and Terraform versions intentionally; test upgrades in non‑prod first.
  • Backups: back up the Keycloak DB and verify restores periodically.

Further Resources#