Keycloak Clusters as Code: The Phase Two Management API, API Credentials and Terraform Provider
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.
| Clusters | Create, inspect, delete; regions, name availability, metrics, restart status |
| Realms | Add and remove realms on a cluster; import an existing realm export |
| Custom domains | Attach a hostname, read the DNS records to create, promote to primary |
| Extensions | Upload custom providers and themes, manage per-version builds |
| Configuration | Custom SPI environment variables; admin and realm IP allow/deny lists |
| Credentials | Per-realm admin credentials for driving Keycloak's own API |
| Operations | List and download logs |
| Billing | Subscriptions, 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.