Using Terraform to Set Up and Configure Keycloak

0


Keycloak is often introduced through its Admin Console: create a realm, add a client, configure redirect URIs, define roles, and repeat the same work in every environment. That approach is convenient for experimentation, but it becomes difficult to audit and reproduce once Keycloak is part of a production platform.

Terraform changes the operating model. Instead of treating identity configuration as a collection of manual console settings, you define the desired state in version-controlled code. Terraform can then create, update, import, and reconcile Keycloak resources through the Keycloak Admin API.

This guide covers a production-oriented workflow for managing Keycloak with the official keycloak/keycloak Terraform provider. It includes:

  • A local Keycloak environment for development.
  • Secure provider authentication.
  • A one-time bootstrap workflow.
  • Realm configuration.
  • OpenID Connect clients for web applications, SPAs, APIs, and machine-to-machine authentication.
  • Realm roles, client roles, composite roles, groups, and users.
  • Client scopes and protocol mappers.
  • Custom authentication flows.
  • Importing an existing manually configured realm.
  • Multi-environment configuration with Terraform modules and Terragrunt.
  • State, secret, drift, and CI/CD considerations.

The old provider source was mrparkers/keycloak. The maintained provider is now published from the official Keycloak organisation as keycloak/keycloak. Existing code should be migrated before adopting current provider versions.

What Terraform Should Manage in Keycloak

Terraform is well suited to configuration that is declarative, reviewable, and expected to remain consistent:

ConfigurationGood Terraform CandidateNotes
RealmsYesRealm deletion should normally be protected.
OIDC and SAML clientsYesRedirect URIs, flows, logout URLs, and scopes benefit from review.
Realm and client rolesYesParticularly useful for consistent RBAC between environments.
Groups and role mappingsYesBe explicit about authoritative versus additive mappings.
Client scopes and protocol mappersYesPrevents token claims from drifting between clients.
Identity providersUsuallySecrets require careful state handling.
Authentication flowsYes, with cautionExecution ordering and bindings must be tested carefully.
Test usersSometimesUseful locally; usually inappropriate for production identities.
Human user passwordsUsually noPrefer federation, invitations, required actions, or external lifecycle systems.
Sessions and transient runtime dataNoTerraform is not a runtime session manager.

Terraform should manage the intended configuration, not every object merely because the provider exposes it. A common and effective split is:

  • Terraform manages realms, clients, roles, groups, scopes, mappers, and authentication policies.
  • An HR, SCIM, directory, or application workflow manages end-user lifecycle.
  • A secret manager supplies provider and client credentials.
  • Keycloak itself manages sessions, tokens, and runtime state.

Prerequisites

The examples use:

  • Terraform 1.11 or later.
  • Keycloak Terraform provider 5.8.x.
  • Keycloak 26.x.
  • Docker Compose for the local example.
  • A remote Terraform backend for shared environments.

Terraform 1.11 or later is recommended because the provider supports write-only client-secret arguments such as client_secret_wo. Write-only arguments allow Terraform to pass a secret to a provider without persisting that value in plan or state files.

Create the following starting structure:

keycloak-terraform/
├── bootstrap/
│   ├── main.tf
│   ├── providers.tf
│   └── variables.tf
├── modules/
│   └── realm/
│       ├── clients.tf
│       ├── groups.tf
│       ├── main.tf
│       ├── outputs.tf
│       ├── roles.tf
│       ├── scopes.tf
│       ├── users.tf
│       └── variables.tf
├── environments/
│   ├── dev/
│   │   ├── backend.tf
│   │   ├── main.tf
│   │   ├── providers.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── backend.tf
│       ├── main.tf
│       ├── providers.tf
│       └── terraform.tfvars
└── docker-compose.yml

Separating bootstrap from normal realm configuration prevents the permanent configuration from depending on a human administrator password.

Start a Local Keycloak Environment

The following Docker Compose file starts Keycloak with PostgreSQL. The versions are pinned deliberately so that development does not change when an upstream latest tag moves.

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - keycloak_postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
      interval: 5s
      timeout: 5s
      retries: 20

  keycloak:
    image: quay.io/keycloak/keycloak:26.6.3
    command: ["start-dev"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  keycloak_postgres:

Start it:

docker compose up -d
docker compose logs -f keycloak

The Admin Console is available at http://localhost:8080/admin/.

start-dev is for local development only. A production Keycloak deployment should use an optimised image, TLS, a production database, an explicitly configured hostname, health checks, metrics, backups, and an appropriate cache topology.

Understand the Bootstrap Problem

Terraform must authenticate to Keycloak before it can create Keycloak resources. However, the recommended machine identity for Terraform is itself a Keycloak confidential client with a service account.

This creates a circular dependency:

  1. Terraform needs a service-account client to authenticate.
  2. The client does not exist until Terraform creates it.

Solve this with two phases:

  1. Bootstrap phase: use temporary administrator credentials to create the permanent Terraform client and assign its administrative role.
  2. Normal phase: stop using the administrator password and authenticate with the dedicated Terraform client.

The Keycloak provider recommends the client-credentials grant for machine-to-machine authentication. The password grant should therefore be limited to bootstrap or recovery scenarios.

Configure the Provider

Use the official provider source and pin its compatible version range:

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    keycloak = {
      source  = "keycloak/keycloak"
      version = "~> 5.8"
    }
  }
}

For normal operation, load the credentials from environment variables or an external secret manager:

variable "keycloak_url" {
  description = "Base URL of the Keycloak server"
  type        = string
}

variable "keycloak_client_id" {
  description = "Client ID used by Terraform"
  type        = string
  default     = "terraform"
}

variable "keycloak_client_secret" {
  description = "Client secret used by Terraform"
  type        = string
  sensitive   = true
  ephemeral   = true
}

variable "keycloak_version" {
  description = "Deployed Keycloak version"
  type        = string
  default     = "26.6.3"
}

provider "keycloak" {
  url              = var.keycloak_url
  realm            = "master"
  client_id        = var.keycloak_client_id
  client_secret    = var.keycloak_client_secret
  keycloak_version = var.keycloak_version
}

Pass variables without committing secrets:

export TF_VAR_keycloak_url="http://localhost:8080"
export TF_VAR_keycloak_client_secret="replace-with-secret"

terraform init
terraform plan

The provider can usually detect the server version. On newer Keycloak 26.x releases, restricted service accounts may not be allowed to read the server information endpoint. Explicitly setting keycloak_version avoids ambiguous provider behaviour when automatic detection is unavailable.

Do not use the following in production:

tls_insecure_skip_verify = true

Skipping certificate validation is acceptable only for tightly controlled local testing. Configure a trusted CA instead.

Bootstrap a Dedicated Terraform Client

Create a separate bootstrap configuration that authenticates with the temporary administrator account.

Bootstrap Variables

variable "keycloak_url" {
  type = string
}

variable "bootstrap_admin_username" {
  type      = string
  sensitive = true
  ephemeral = true
}

variable "bootstrap_admin_password" {
  type      = string
  sensitive = true
  ephemeral = true
}

variable "terraform_client_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

Bootstrap Provider

provider "keycloak" {
  url       = var.keycloak_url
  realm     = "master"
  client_id = "admin-cli"
  username  = var.bootstrap_admin_username
  password  = var.bootstrap_admin_password
}

Terraform Service-Account Client

resource "keycloak_openid_client" "terraform" {
  realm_id = "master"

  client_id = "terraform"
  name      = "Terraform automation"
  enabled   = true

  access_type                  = "CONFIDENTIAL"
  standard_flow_enabled        = false
  implicit_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = true
  full_scope_allowed           = false

  client_secret_wo         = var.terraform_client_secret
  client_secret_wo_version = "1"
}

Assign the built-in admin realm role to the client’s service account:

resource "keycloak_openid_client_service_account_realm_role" "terraform_admin" {
  realm_id = "master"

  service_account_user_id = keycloak_openid_client.terraform.service_account_user_id
  role                    = "admin"
}

For a production platform, full admin access may be broader than necessary. A more restrictive design can assign realm-specific administration roles or selected roles such as manage-clients, manage-users, and view-realm. Start with least privilege whenever your environment model allows it.

Run the bootstrap once:

export TF_VAR_keycloak_url="http://localhost:8080"
export TF_VAR_bootstrap_admin_username="admin"
export TF_VAR_bootstrap_admin_password="admin"
export TF_VAR_terraform_client_secret="$(openssl rand -base64 48)"

terraform -chdir=bootstrap init
terraform -chdir=bootstrap apply

Store the generated client secret in a proper secret manager. Then remove the bootstrap administrator credentials from the shell and use the service-account client for all normal operations.

A write-only argument prevents Terraform from storing the client secret. It does not store the secret somewhere else for you. The value must be retained in a secret manager if it will be needed again.

Create a Realm

A realm is the main isolation boundary for users, roles, clients, groups, policies, and sessions.

variable "realm_name" {
  type = string
}

variable "display_name" {
  type = string
}

resource "keycloak_realm" "this" {
  realm        = var.realm_name
  display_name = var.display_name
  enabled      = true

  ssl_required = "external"

  registration_allowed         = false
  reset_password_allowed       = true
  remember_me                  = true
  verify_email                 = true
  login_with_email_allowed     = true
  duplicate_emails_allowed     = false
  edit_username_allowed        = false

  access_code_lifespan         = "5m"
  access_token_lifespan        = "5m"
  sso_session_idle_timeout     = "30m"
  sso_session_max_lifespan     = "10h"
  offline_session_idle_timeout = "720h"

  password_policy = join(" and ", [
    "length(14)",
    "upperCase(1)",
    "lowerCase(1)",
    "digits(1)",
    "specialChars(1)",
    "notUsername",
  ])

  internationalization {
    supported_locales = ["en", "nl", "fr"]
    default_locale    = "en"
  }

  terraform_deletion_protection = true
}

Deletion protection is particularly important for realms. Destroying a realm removes its clients, roles, groups, users, credentials, and most realm-level configuration. Treat a realm deletion as a high-risk operation.

You can add an additional Terraform lifecycle guard:

resource "keycloak_realm" "this" {
  # Realm configuration omitted.

  lifecycle {
    prevent_destroy = true
  }
}

terraform_deletion_protection is enforced by the provider, while prevent_destroy is enforced by Terraform. Using both creates defence in depth.

Configure OIDC Clients

Different application types require different client settings. Avoid copying one permissive client configuration to every application.

Confidential Server-Side Web Application

A server-side web application can safely hold a client secret.

resource "keycloak_openid_client" "web" {
  realm_id = keycloak_realm.this.id

  client_id   = "customer-portal"
  name        = "Customer Portal"
  description = "Server-side customer portal"
  enabled     = true

  access_type                  = "CONFIDENTIAL"
  standard_flow_enabled        = true
  implicit_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = false
  full_scope_allowed           = false

  pkce_code_challenge_method = "S256"

  root_url = "https://portal.example.com"

  valid_redirect_uris = [
    "/auth/callback",
  ]

  valid_post_logout_redirect_uris = [
    "/",
  ]

  web_origins = [
    "https://portal.example.com",
  ]
}

Use exact redirect URIs wherever possible. Broad wildcards increase the damage of an open-redirect or application-routing vulnerability.

Public Single-Page Application

A browser SPA cannot securely keep a client secret. Use a public client with Authorization Code Flow and PKCE.

resource "keycloak_openid_client" "spa" {
  realm_id = keycloak_realm.this.id

  client_id = "customer-spa"
  name      = "Customer SPA"
  enabled   = true

  access_type                  = "PUBLIC"
  standard_flow_enabled        = true
  implicit_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = false
  full_scope_allowed           = false

  pkce_code_challenge_method = "S256"

  valid_redirect_uris = [
    "https://app.example.com/auth/callback",
  ]

  valid_post_logout_redirect_uris = [
    "https://app.example.com/",
  ]

  web_origins = [
    "https://app.example.com",
  ]
}

The OAuth 2.0 Implicit Grant and Resource Owner Password Grant should not be enabled merely because an application library supports them.

Bearer-Only API

An API that only validates bearer tokens and never initiates login can use a bearer-only client:

resource "keycloak_openid_client" "api" {
  realm_id = keycloak_realm.this.id

  client_id = "orders-api"
  name      = "Orders API"
  enabled   = true

  access_type                  = "BEARER-ONLY"
  standard_flow_enabled        = false
  implicit_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = false
  full_scope_allowed           = false
}

Machine-to-Machine Client

Use a confidential client with service accounts for the Client Credentials Grant:

variable "worker_client_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

variable "worker_client_secret_version" {
  type    = string
  default = "1"
}

resource "keycloak_openid_client" "worker" {
  realm_id = keycloak_realm.this.id

  client_id = "invoice-worker"
  name      = "Invoice Worker"
  enabled   = true

  access_type                  = "CONFIDENTIAL"
  standard_flow_enabled        = false
  implicit_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = true
  full_scope_allowed           = false

  client_secret_wo         = var.worker_client_secret
  client_secret_wo_version = var.worker_client_secret_version
}

Increment worker_client_secret_version when intentionally rotating the secret.

The provider also supports secret regeneration triggers, but automated rotation is useful only when the new secret is delivered safely to the consuming workload. Rotating a secret in Keycloak without updating the application creates an outage.

Create Realm Roles and Client Roles

Use realm roles for permissions shared across the realm. Use client roles for permissions owned by a specific application or API.

Realm Roles

resource "keycloak_role" "employee" {
  realm_id   = keycloak_realm.this.id
  name       = "employee"
  description = "Base role for employees"
}

resource "keycloak_role" "support_agent" {
  realm_id    = keycloak_realm.this.id
  name        = "support-agent"
  description = "Can use support tooling"
}

Client Roles

resource "keycloak_role" "orders_read" {
  realm_id = keycloak_realm.this.id
  client_id = keycloak_openid_client.api.id

  name        = "orders:read"
  description = "Read orders"
}

resource "keycloak_role" "orders_write" {
  realm_id = keycloak_realm.this.id
  client_id = keycloak_openid_client.api.id

  name        = "orders:write"
  description = "Create and update orders"
}

resource "keycloak_role" "orders_admin" {
  realm_id = keycloak_realm.this.id
  client_id = keycloak_openid_client.api.id

  name        = "orders:admin"
  description = "Full orders administration"

  composite_roles = [
    keycloak_role.orders_read.id,
    keycloak_role.orders_write.id,
  ]
}

Composite roles reduce repetitive assignments. A user or group assigned orders:admin inherits the component roles.

Keep role names stable. Renaming a role can affect:

  • Application authorization checks.
  • Token contents.
  • Group mappings.
  • Service-account permissions.
  • External systems that store role names.

Create Groups and Assign Roles

Groups are usually the cleanest way to assign role bundles.

resource "keycloak_group" "employees" {
  realm_id = keycloak_realm.this.id
  name     = "employees"

  attributes = {
    department_type = "internal"
  }
}

resource "keycloak_group" "support" {
  realm_id  = keycloak_realm.this.id
  parent_id = keycloak_group.employees.id
  name      = "support"
}

resource "keycloak_group" "order_admins" {
  realm_id = keycloak_realm.this.id
  name     = "order-admins"
}

Assign roles to groups:

resource "keycloak_group_roles" "employees" {
  realm_id = keycloak_realm.this.id
  group_id = keycloak_group.employees.id

  role_ids = [
    keycloak_role.employee.id,
  ]
}

resource "keycloak_group_roles" "support" {
  realm_id = keycloak_realm.this.id
  group_id = keycloak_group.support.id

  role_ids = [
    keycloak_role.support_agent.id,
    keycloak_role.orders_read.id,
  ]
}

resource "keycloak_group_roles" "order_admins" {
  realm_id = keycloak_realm.this.id
  group_id = keycloak_group.order_admins.id

  role_ids = [
    keycloak_role.orders_admin.id,
  ]
}

By default, keycloak_group_roles is authoritative. If somebody manually adds another role in the Admin Console, Terraform removes it during the next apply.

Use additive management only when another system is also expected to manage roles:

resource "keycloak_group_roles" "external_addition" {
  realm_id  = keycloak_realm.this.id
  group_id  = keycloak_group.support.id
  exhaustive = false

  role_ids = [
    keycloak_role.orders_read.id,
  ]
}

Do not mix authoritative and additive resources for the same relationship without a deliberate ownership model.

Manage Users Carefully

Terraform can create Keycloak users, but that does not mean it should own production human identities.

A development-only example:

resource "keycloak_user" "alice" {
  realm_id = keycloak_realm.this.id

  username   = "alice"
  email      = "alice@example.com"
  first_name = "Alice"
  last_name  = "Example"
  enabled    = true

  email_verified = true

  attributes = {
    tenant_id = "tenant-a"
  }

  initial_password {
    value     = "ChangeMe-Immediately-123!"
    temporary = true
  }
}

The initial password is sensitive and may enter Terraform state depending on the resource implementation. Avoid this pattern in shared or production environments.

Assign a user to groups:

resource "keycloak_user_groups" "alice" {
  realm_id = keycloak_realm.this.id
  user_id  = keycloak_user.alice.id

  group_ids = [
    keycloak_group.employees.id,
    keycloak_group.support.id,
  ]
}

keycloak_user_groups is authoritative by default. Any manual group membership not present in group_ids is removed on the next apply.

For production users, prefer one of the following:

  • LDAP or Active Directory federation.
  • An external identity provider.
  • SCIM provisioning.
  • A dedicated user-management service.
  • Keycloak required actions and invitation workflows.

Add Client Scopes and Protocol Mappers

Client scopes let you reuse token mapping configuration across multiple clients.

Create a scope for tenant information:

resource "keycloak_openid_client_scope" "tenant" {
  realm_id = keycloak_realm.this.id

  name                                = "tenant"
  description                         = "Adds tenant information to tokens"
  include_in_token_scope              = true
  include_in_openid_provider_metadata = true
}

Map a Keycloak user attribute to a token claim:

resource "keycloak_openid_user_attribute_protocol_mapper" "tenant_id" {
  realm_id        = keycloak_realm.this.id
  client_scope_id = keycloak_openid_client_scope.tenant.id

  name           = "tenant-id"
  user_attribute = "tenant_id"
  claim_name     = "tenant_id"
  claim_value_type = "String"

  multivalued               = false
  add_to_id_token           = true
  add_to_access_token       = true
  add_to_userinfo           = true
  add_to_token_introspection = true
}

Attach the scope as a default scope:

resource "keycloak_openid_client_default_scopes" "web" {
  realm_id  = keycloak_realm.this.id
  client_id = keycloak_openid_client.web.id

  default_scopes = [
    "profile",
    "email",
    "roles",
    "web-origins",
    keycloak_openid_client_scope.tenant.name,
  ]
}

This resource is authoritative. Keycloak normally attaches built-in scopes such as profile, email, roles, and web-origins to new OIDC clients. Include the built-in scopes you want to retain, or Terraform will remove them.

Use optional scopes when an application should request the claim explicitly through the OAuth scope parameter. Use default scopes when the claim should be included without an explicit request.

Give a Service Account Access to an API

The machine client invoice-worker needs the orders:read client role from orders-api.

resource "keycloak_openid_client_service_account_role" "worker_orders_read" {
  realm_id = keycloak_realm.this.id

  service_account_user_id = keycloak_openid_client.worker.service_account_user_id
  client_id               = keycloak_openid_client.api.id
  role                    = keycloak_role.orders_read.name
}

The important distinction is:

  • service_account_user_id identifies the client receiving the role.
  • client_id identifies the client that owns the role.

To test the service account:

curl --request POST \
  --url "https://sso.example.com/realms/platform/protocol/openid-connect/token" \
  --header "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_id=invoice-worker" \
  --data-urlencode "client_secret=${INVOICE_WORKER_SECRET}"

Decode the access token and confirm that the expected client role appears in resource_access.

Create a Custom Authentication Flow

Authentication flows are more sensitive than ordinary realm settings because execution order changes behaviour.

The following simplified browser flow checks an existing SSO cookie and otherwise redirects through the configured identity-provider redirector:

resource "keycloak_authentication_flow" "browser" {
  realm_id    = keycloak_realm.this.id
  alias       = "terraform-browser"
  description = "Browser flow managed by Terraform"
}

resource "keycloak_authentication_execution" "cookie" {
  realm_id          = keycloak_realm.this.id
  parent_flow_alias = keycloak_authentication_flow.browser.alias
  authenticator     = "auth-cookie"
  requirement       = "ALTERNATIVE"
}

resource "keycloak_authentication_execution" "idp_redirector" {
  realm_id          = keycloak_realm.this.id
  parent_flow_alias = keycloak_authentication_flow.browser.alias
  authenticator     = "identity-provider-redirector"
  requirement       = "ALTERNATIVE"

  depends_on = [
    keycloak_authentication_execution.cookie,
  ]
}

resource "keycloak_authentication_bindings" "browser" {
  realm_id    = keycloak_realm.this.id
  browser_flow = keycloak_authentication_flow.browser.alias
}

Execution ordering matters, so explicit depends_on relationships are useful even when Terraform could otherwise create the resources in parallel.

Do not manage the same binding through both keycloak_realm and keycloak_authentication_bindings. Choose one owner.

Test authentication flows in a disposable realm before applying them to production. A broken browser flow can lock out administrators and users.

Turn the Configuration into a Reusable Module

A root environment can call the realm module with environment-specific settings:

module "platform_realm" {
  source = "../../modules/realm"

  realm_name  = "platform-dev"
  display_name = "Platform Development"

  web_base_url = "https://dev-portal.example.com"

  worker_client_secret         = var.worker_client_secret
  worker_client_secret_version = var.worker_client_secret_version
}

Example module variables:

variable "realm_name" {
  type = string
}

variable "display_name" {
  type = string
}

variable "web_base_url" {
  type = string

  validation {
    condition     = startswith(var.web_base_url, "https://")
    error_message = "web_base_url must use HTTPS."
  }
}

variable "worker_client_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

variable "worker_client_secret_version" {
  type = string
}

Build URLs from one validated base value:

locals {
  web_redirect_uri = "${var.web_base_url}/auth/callback"
  web_logout_uri   = "${var.web_base_url}/"
}

Avoid a module with hundreds of loosely typed settings. A good module expresses your organisation’s supported Keycloak patterns, not the entire provider schema.

Manage Multiple Environments with Terragrunt

Terragrunt is useful when several environments share the same provider, backend, module layout, and variable conventions.

A simple structure:

live/
├── terragrunt.hcl
├── dev/
│   ├── env.hcl
│   └── terragrunt.hcl
├── staging/
│   ├── env.hcl
│   └── terragrunt.hcl
└── prod/
    ├── env.hcl
    └── terragrunt.hcl

Root terragrunt.hcl:

remote_state {
  backend = "s3"

  config = {
    bucket       = "company-terraform-state"
    key          = "keycloak/${path_relative_to_include()}/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }

  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}

generate "versions" {
  path      = "versions.tf"
  if_exists = "overwrite_terragrunt"

  contents = <<-EOF
    terraform {
      required_version = ">= 1.11.0"

      required_providers {
        keycloak = {
          source  = "keycloak/keycloak"
          version = "~> 5.8"
        }
      }
    }
  EOF
}

Environment configuration:

locals {
  environment = "dev"
  realm_name  = "platform-dev"
  base_url    = "https://dev-portal.example.com"
}

Environment terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

locals {
  env = read_terragrunt_config("env.hcl")
}

terraform {
  source = "../../modules/realm"
}

inputs = {
  realm_name   = local.env.locals.realm_name
  display_name = "Platform Development"
  web_base_url = local.env.locals.base_url
}

Keep secrets out of env.hcl and terragrunt.hcl. Supply them from a CI secret store, Vault, cloud secret manager, or another ephemeral source.

The supplied DEV Community tutorial demonstrates the same broad pattern: shared modules, separate environment directories, generated provider/backend files, and remote state. Its provider source and versions are historical, so update them to keycloak/keycloak and a current compatible version.

Import Existing Keycloak Configuration

Most real Keycloak installations already contain manually created realms and clients. Do not recreate those resources and hope Terraform recognises them. Import them into state.

Import a Realm

Define the target resource:

resource "keycloak_realm" "platform" {
  realm  = "platform"
  enabled = true
}

Use an import block:

import {
  to = keycloak_realm.platform
  id = "platform"
}

Then run:

terraform plan
terraform apply

Import a Client

Keycloak clients are commonly imported using the realm and the internal client UUID:

import {
  to = keycloak_openid_client.web
  id = "platform/2de32a25-baa2-4b82-8f45-b22de8b8311a"
}

The exact import ID format differs by resource. Always check the provider documentation for that resource.

Recommended Import Workflow

  1. Back up the realm.
  2. Create a resource block with the known identity fields.
  3. Add an import block or run terraform import.
  4. Run terraform plan.
  5. Copy the observed configuration into code.
  6. Remove computed and irrelevant attributes.
  7. Repeat until the plan is empty or contains only intentional changes.
  8. Review any destructive action before applying.
  9. Keep import changes in a dedicated pull request.

Terraform import does not automatically prove that your code matches the existing remote configuration. The first post-import plan is the critical review point.

Some relationship resources, including certain default-scope and user-group resources, do not support import. They can often be declared directly, after which Terraform reconciles the existing relationship.

Protect Secrets and Terraform State

Marking a variable as sensitive only hides it from normal CLI output. It does not necessarily prevent storage in Terraform state.

Use the following controls:

  • Store state remotely.
  • Encrypt state at rest.
  • Use TLS in transit.
  • Enable state locking.
  • Restrict state access to the smallest possible group.
  • Separate bootstrap state from realm-configuration state.
  • Avoid committing plan files.
  • Avoid logging environment variables in CI.
  • Use ephemeral variables for provider credentials.
  • Use write-only provider arguments for client secrets when available.
  • Treat state backups and replicas as sensitive.
  • Do not expose state through broad terraform_remote_state access.

Provider credentials can be passed through ephemeral variables:

variable "keycloak_client_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

Client secrets can use write-only arguments:

resource "keycloak_openid_client" "service" {
  realm_id = keycloak_realm.this.id
  client_id = "service"

  access_type              = "CONFIDENTIAL"
  service_accounts_enabled = true

  client_secret_wo         = var.service_client_secret
  client_secret_wo_version = var.service_client_secret_version
}

Do not expose the secret as an output merely to move it into another Terraform state. Deliver it directly to a secret manager or inject a pre-existing secret into both Keycloak and the workload.

CI/CD Workflow

A safe pipeline separates validation, planning, approval, and application.

Example GitHub Actions structure:

name: Keycloak Terraform

on:
  pull_request:
    paths:
      - "keycloak/**"
  push:
    branches:
      - main
    paths:
      - "keycloak/**"

permissions:
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: keycloak/environments/dev

    env:
      TF_IN_AUTOMATION: "true"
      TF_INPUT: "false"
      TF_VAR_keycloak_url: ${{ secrets.KEYCLOAK_URL }}
      TF_VAR_keycloak_client_secret: ${{ secrets.KEYCLOAK_TERRAFORM_CLIENT_SECRET }}
      TF_VAR_worker_client_secret: ${{ secrets.KEYCLOAK_WORKER_CLIENT_SECRET }}

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.11.4"

      - name: Terraform format
        run: terraform fmt -check -recursive ../..

      - name: Terraform init
        run: terraform init

      - name: Terraform validate
        run: terraform validate

      - name: Terraform plan
        run: terraform plan -out=tfplan

      - name: Terraform apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan

For production:

  • Use a protected environment.
  • Require manual approval.
  • Restrict who can modify the workflow and Terraform modules.
  • Use short-lived credentials where possible.
  • Archive the human-readable plan without exposing secrets.
  • Run terraform apply only against the reviewed plan.
  • Prevent simultaneous applies with backend locking.
  • Add smoke tests for OIDC discovery, token issuance, redirect URIs, and expected claims.

A useful smoke test retrieves the realm’s discovery document:

curl --fail --silent \
  "https://sso.example.com/realms/platform/.well-known/openid-configuration" \
  | jq -e '.issuer and .authorization_endpoint and .token_endpoint'

A second test can request a service-account token and verify expected claims.

Handle Configuration Drift Deliberately

Terraform detects drift when the remote object differs from state and configuration. Whether Terraform reverses the manual change depends on the resource’s semantics.

Authoritative resources are especially important in Keycloak:

  • keycloak_group_roles removes manually added roles when exhaustive = true.
  • keycloak_user_groups removes manually added memberships when exhaustive = true.
  • keycloak_openid_client_default_scopes replaces the client’s default-scope set with the configured list.

Define an ownership rule:

  • Production changes go through Terraform.
  • Emergency console changes must be followed by a code change or intentionally reverted.
  • Read-only teams may inspect the Admin Console but should not mutate managed resources.
  • Unmanaged configuration should be documented explicitly.

Avoid broad lifecycle.ignore_changes rules. They can silence legitimate drift and leave security-sensitive settings unmanaged.

Common Pitfalls

Using the Legacy Provider Source

Old examples commonly use:

source = "mrparkers/keycloak"

Use:

source = "keycloak/keycloak"

Hardcoding Administrator Credentials

Do not commit this:

provider "keycloak" {
  username = "admin"
  password = "admin"
}

Use temporary bootstrap credentials once, then switch to client credentials.

Reusing the master Realm for Applications

The master realm is intended to administer the Keycloak server. Create separate realms for applications and users.

Enabling Too Many OAuth Flows

Do not enable implicit flow, password grant, standard flow, and service accounts on every client. Enable only the flow required by that application type.

Using Broad Redirect URI Wildcards

Avoid:

valid_redirect_uris = ["*"]

Prefer exact HTTPS URLs.

Forgetting Built-In Default Scopes

When managing default scopes authoritatively, include built-ins you intend to keep:

default_scopes = [
  "profile",
  "email",
  "roles",
  "web-origins",
]

Confusing Client IDs and Internal UUIDs

Keycloak exposes both:

  • Human-readable client ID, such as orders-api.
  • Internal UUID assigned by Keycloak.

Many Terraform resources expect the internal ID:

client_id = keycloak_openid_client.api.id

Check the argument description instead of assuming that client_id always means the human-readable value.

Storing Generated Secrets Without a Delivery Path

A secret generated only inside Terraform is useless if the consuming workload cannot retrieve it. Design secret generation, storage, rotation, and workload delivery as one workflow.

Importing and Applying Immediately

After import, inspect the plan carefully. An incomplete resource block can cause Terraform to overwrite valid settings or destroy related resources.

Managing Users as Infrastructure

Terraform is not usually the right lifecycle system for a large production user population. Use it for configuration and small controlled service identities, not routine employee onboarding.

A Compact End-to-End Example

The following condensed example creates a realm, API client, machine client, role, group, and service-account assignment:

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    keycloak = {
      source  = "keycloak/keycloak"
      version = "~> 5.8"
    }
  }
}

variable "keycloak_url" {
  type = string
}

variable "keycloak_client_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

variable "worker_secret" {
  type      = string
  sensitive = true
  ephemeral = true
}

provider "keycloak" {
  url              = var.keycloak_url
  realm            = "master"
  client_id        = "terraform"
  client_secret    = var.keycloak_client_secret
  keycloak_version = "26.6.3"
}

resource "keycloak_realm" "platform" {
  realm        = "platform"
  display_name = "Platform"
  enabled      = true

  ssl_required                 = "external"
  reset_password_allowed       = true
  verify_email                 = true
  login_with_email_allowed     = true
  terraform_deletion_protection = true
}

resource "keycloak_openid_client" "api" {
  realm_id = keycloak_realm.platform.id

  client_id  = "orders-api"
  name       = "Orders API"
  enabled    = true
  access_type = "BEARER-ONLY"

  standard_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = false
  full_scope_allowed           = false
}

resource "keycloak_openid_client" "worker" {
  realm_id = keycloak_realm.platform.id

  client_id = "invoice-worker"
  name      = "Invoice Worker"
  enabled   = true

  access_type                  = "CONFIDENTIAL"
  standard_flow_enabled        = false
  direct_access_grants_enabled = false
  service_accounts_enabled     = true
  full_scope_allowed           = false

  client_secret_wo         = var.worker_secret
  client_secret_wo_version = "1"
}

resource "keycloak_role" "orders_read" {
  realm_id = keycloak_realm.platform.id
  client_id = keycloak_openid_client.api.id

  name        = "orders:read"
  description = "Read orders"
}

resource "keycloak_openid_client_service_account_role" "worker_orders_read" {
  realm_id = keycloak_realm.platform.id

  service_account_user_id = keycloak_openid_client.worker.service_account_user_id
  client_id               = keycloak_openid_client.api.id
  role                    = keycloak_role.orders_read.name
}

resource "keycloak_group" "support" {
  realm_id = keycloak_realm.platform.id
  name     = "support"
}

resource "keycloak_group_roles" "support" {
  realm_id = keycloak_realm.platform.id
  group_id = keycloak_group.support.id

  role_ids = [
    keycloak_role.orders_read.id,
  ]
}

Run it:

terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

Then request a service-account token and inspect its roles.

Final Recommendations

Use Terraform for Keycloak when you need repeatability, review, auditability, and drift control across multiple environments. The strongest implementation usually has these properties:

  • The official keycloak/keycloak provider is pinned.
  • Administrator-password authentication is limited to bootstrap.
  • Terraform uses a dedicated confidential client and service account.
  • Realms are protected from accidental deletion.
  • OAuth flows are enabled per application type, not by default.
  • Redirect URIs and web origins are explicit.
  • Roles are assigned through groups where possible.
  • Client scopes centralise reusable claims.
  • Relationship resources have a clear authoritative or additive ownership model.
  • Human user lifecycle is delegated to an appropriate identity system.
  • Provider and application secrets do not enter source control.
  • State is remote, encrypted, locked, and access-controlled.
  • Existing resources are imported and reconciled before the first apply.
  • Production applies require a reviewed plan and approval.
  • Authentication and token claims are smoke-tested after changes.

Terraform does not remove the need to understand Keycloak. It makes that understanding explicit, reviewable, and repeatable.

References

  • Keycloak Terraform Provider - github[.]com/keycloak/terraform-provider-keycloak
  • Keycloak Terraform Provider Documentation - registry[.]terraform[.]io/providers/keycloak/keycloak/latest/docs
  • Keycloak Terraform Provider: OpenID Client - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/openid_client.md
  • Keycloak Terraform Provider: Realm - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/realm.md
  • Keycloak Terraform Provider: Roles - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/role.md
  • Keycloak Terraform Provider: Groups - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/group.md
  • Keycloak Terraform Provider: Client Scopes - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/openid_client_scope.md
  • Keycloak Terraform Provider: User Attribute Protocol Mapper - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/openid_user_attribute_protocol_mapper.md
  • Keycloak Terraform Provider: Authentication Bindings - github[.]com/keycloak/terraform-provider-keycloak/blob/main/docs/resources/authentication_bindings.md
  • Keycloak: Get Started with Docker - keycloak[.]org/getting-started/getting-started-docker
  • Keycloak: Running Keycloak in a Container - keycloak[.]org/server/containers
  • Keycloak: Bootstrapping and Recovering an Admin Account - keycloak[.]org/server/bootstrap-admin-recovery
  • Keycloak: Importing and Exporting Realms - keycloak[.]org/server/importExport
  • Terraform: Manage Sensitive Data - developer[.]hashicorp[.]com/terraform/language/manage-sensitive-data
  • Terraform: Write-Only Arguments - developer[.]hashicorp[.]com/terraform/language/manage-sensitive-data/write-only
  • Terraform: Import Existing Resources - developer[.]hashicorp[.]com/terraform/language/import
  • Terragrunt Documentation - terragrunt[.]gruntwork[.]io/docs
  • Simplifying Keycloak Configuration with Terraform and Terragrunt - dev[.]to/mohammedalics/simplifying-keycloak-configuration-with-terraform-and-terragrunt-3ohm

Post a Comment

0 Comments

Post a Comment (0)

#buttons=(Ok, Go it!) #days=(20)

This site uses cookies from Google to deliver its services and analyze traffic. Your IP address and user-agent are shared with Google along with performance and security metrics to ensure quality of service, generate usage statistics, and to detect and address abuse. More Info
Ok, Go it!