Skip to main content

Keycloak Clusters as Code: The Phase Two Management API, API Credentials and Terraform Provider

· 9 min read
GR Patil
Phase Two

Everything you can do in the Phase Two console, you can now do from code.

Create a dedicated Keycloak cluster. Add a realm to it. Attach a custom domain and watch the certificate get issued. Upload a custom provider. Restrict the admin console to your office IP range. Pull yesterday's logs.

Three things shipped together, because none of them is much use alone: the Management API, the API credentials that authenticate to it, and a Terraform provider generated from the same spec.

All three are experimental for now. The console marks API credentials as such, and the Terraform provider is at 0.1.0. Point them at test environments rather than production while the surface settles.

Why this exists

Phase Two runs dedicated Keycloak clusters. Until now, provisioning one meant clicking through the console — fine the first time, less fine when you are standing up a realm per customer, rebuilding a staging environment weekly, or trying to make a production change with a reviewable audit trail.

The console was the only interface. Now it is one of three.

The Management API

82 endpoints in nine groups. It is the control plane: it manages clusters themselves and everything attached to them.

ClustersCreate, inspect, delete; regions, name availability, metrics, restart status
RealmsAdd and remove realms on a cluster; import an existing realm export
Custom domainsAttach a hostname, read the DNS records to create, promote to primary
ExtensionsUpload custom providers and themes, manage per-version builds
ConfigurationCustom SPI environment variables; admin and realm IP allow/deny lists
CredentialsPer-realm admin credentials for driving Keycloak's own API
OperationsList and download logs
BillingSubscriptions, payment methods, billing contacts

What it deliberately does not do is reach inside a realm. There is no endpoint here to create a user or a client — those belong to Keycloak's own Admin REST API, and to our extensions to it, pointed at your cluster.

That division is the thing people trip on, so it is worth stating once: the Management API gets you a realm; Keycloak's API configures it. One creates the box, the other works inside it.

API credentials

An API secret is an OIDC client-credentials client scoped to your team. Create one in the console under your team's API Credentials tab — client ID and secret, and the secret is shown exactly once.

Then the standard exchange:

TOKEN=$(curl -s -X POST \
https://app.phasetwo.io/auth/realms/self/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id="$PHASETWO_CLIENT_ID" \
-d client_secret="$PHASETWO_CLIENT_SECRET" | jq -r .access_token)

curl -s https://api.phasetwo.io/v2/clusters -H "Authorization: Bearer $TOKEN" | jq

Note the two hostnames. You authenticate against app.phasetwo.io — the console host, which owns the token endpoint — and you call api.phasetwo.io. Two different hosts in one script is unusual enough to deserve a comment where you write it down.

Least privilege is real here

A secret holds organization roles, and the API checks them per operation. A secret created with only view roles returns 200 on reads and 403 on writes:

# a monitoring job's secret: view roles only
curl -s $API/clusters/$ID/metrics -H "Authorization: Bearer $TOKEN" # 200
curl -s -X DELETE $API/clusters/$ID -H "Authorization: Bearer $TOKEN" # 403

You can only grant roles you hold yourself, so a secret cannot escalate. Create one per consumer rather than one shared credential with everything — revoking a narrow secret then does not break four other jobs. Ten per organization.

The Terraform provider

Experimental — test environments only. It is 0.1.0: resource and attribute shapes may still change in backwards-incompatible ways, and what it manages is real, billable infrastructure. Replacing a phasetwo_cluster destroys it and every realm on it, and destroying one keeps billing and holds the name until the end of the billing cycle. Pin an exact version while it is at 0.x, and try it somewhere you do not mind breaking first.

The same surface, declaratively. internal/client is generated by oapi-codegen from the same OpenAPI document that generates the API reference, so the two cannot drift.

data "phasetwo_organization" "team" {
name = "acme"
}

data "phasetwo_payment_method" "default" {
organization_id = data.phasetwo_organization.team.id
default = true
}

resource "phasetwo_cluster" "main" {
name = "acme-prod"
region = "US_EAST_1"
tier = "premium"
organization_id = data.phasetwo_organization.team.id
payment_method_id = data.phasetwo_payment_method.default.id
}

resource "phasetwo_realm" "production" {
cluster_id = phasetwo_cluster.main.id
name = "production"
}

Organizations and payment methods are referenced, not managed — both involve flows that only make sense in a browser, so you create them in the console and look them up.

The reason to use the provider rather than the API directly is that the interesting parts of cluster management are exactly the parts that are annoying to write by hand, and the provider has already written them. Provisioning is asynchronous, so something has to poll for ACTIVE. Environment variable and extension changes restart Keycloak, and the API rejects a second restart while one is in flight, so they have to be serialized and waited on. Deletes are deferred. Custom domains need DNS records you create between two API calls.

Every one of those is a loop, a retry or an ordering constraint in a shell script. In the provider they are the provider's problem. The honest trade: an apply that touches many environment variables takes as long as that many Keycloak restarts. It is not free; it is just correct.

The part we are actually pleased with

Terraform's classic dead end is configuring a provider from a resource created in the same apply. Standing up a Keycloak cluster and then configuring the realm inside it is exactly that shape, and it is why "use the Keycloak provider afterwards" used to mean a second root module and a manual credential handoff.

It is now one apply:

resource "phasetwo_realm_credential" "terraform" {
realm_id = phasetwo_realm.production.id
name = "terraform"
}

ephemeral "phasetwo_realm_credential_secret" "terraform" {
realm_id = phasetwo_realm.production.id
client_id = phasetwo_realm_credential.terraform.client_id
}

provider "keycloak" {
url = phasetwo_realm_credential.terraform.server_url
realm = phasetwo_realm_credential.terraform.realm
client_id = phasetwo_realm_credential.terraform.client_id
client_secret = ephemeral.phasetwo_realm_credential_secret.terraform.client_secret
initial_login = false
}

terraform apply creates the cluster, creates the realm, mints a credential on that realm, and uses it to configure clients, identity providers and flows inside — in one pass. terraform destroy orders correctly too, because Terraform infers the dependency through the provider block.

Two things in there are doing more work than they look like they are.

ephemeral keeps a realm-admin secret out of your state file. Terraform writes every resource attribute to terraform.tfstate, which is very often an unencrypted file in an object store. Ephemeral resources are never written to state or plan files, so the secret exists only for the duration of the run. That is why phasetwo_realm_credential has no client_secret attribute at all — exposing one would quietly undo the whole arrangement. Needs Terraform 1.10 or later.

initial_login = false is not optional. The Keycloak provider authenticates when Terraform configures it, which happens during plan — against a cluster that does not exist yet. Without it you get failed to perform initial login to Keycloak: ... 401 Unauthorized before anything has been created. The trade is that a wrong credential is no longer caught at plan time; it surfaces during apply.

It is your credential, not ours

A realm credential is a service-account client on your realm, api-terraform-9f3c1a2b, with the realm-management roles you asked for — realm-admin by default, narrower if you say so:

curl -s -X POST "https://api.phasetwo.io/v2/deployments/$DEPLOYMENT_ID/credentials" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "audit", "roles": ["view-users", "view-realm", "view-events"]}'

It is deliberately not the client Phase Two administers your realm with. Handing you ours would put our credential in your state file, where a leak would compromise our access and we could not rotate without breaking ourselves. Different holders, different lifetimes, different revocation needs.

We keep no copy of the secret. Listing, revocation and read-back all work by asking your realm — which is also why fetching it on every apply is safe: reading does not rotate it. Create one per pipeline, per environment or per engineer, and a leak costs you one revocation rather than a rotation everywhere.

Three things that will bite you

We would rather tell you now than have you find out on a Friday.

Provisioning is asynchronous. POST /clusters returns before the cluster exists. Poll status until ACTIVE; don't sleep-and-hope.

Some changes restart Keycloak. Adding an environment variable or reconciling extensions restarts the cluster's Keycloak. The API refuses a second one while the first is in flight — a 409, deliberately, rather than queuing it silently.

Deleting a cluster is deferred, and the name stays reserved. Unless it never completed billing setup, delete moves the cluster to PENDING_DELETION, bills to the end of the period, and holds its name. A create/destroy/recreate loop on one name will fail on the second create. Use distinct names in ephemeral environments.

And one more, specific to the API: supply payment_method_id on cluster create and the subscription is charged directly. Omit it and you get a Stripe Checkout link back instead — which needs a browser and cannot be completed from a script. The response has three documented shapes; branch on which one you got.

The spec is the product

The whole surface is one OpenAPI 3 document, and it is what generates both the reference pages and the Terraform client:

Operation IDs are {resource}.{operation}cluster.list, cluster.domain.detail, org.apiSecret.create — mirroring the URL hierarchy, so an ID tells you where an endpoint sits without a lookup. Generated clients derive method names from them, which is also why they are stable.

Point a generator at it and you have a client in whatever language you like.

Start here

  • API keys — create a secret, get a token, make a call
  • Automation recipes — provision a cluster, onboard a tenant, attach a domain, rotate credentials
  • Terraform provider (experimental) — setup, resources, and the surprises above in full
  • API reference — all 82 endpoints with try-it

Available now to every Phase Two account. If you build something with it we would like to hear about it.