Skip to content
All articles

What a Kubernetes operator can do that Terraform cannot

Managing OAuth clients with a reconciling operator or with Terraform: drift, secrets in state, who holds the admin credential, and where Terraform wins.

By Michael Beutler, FounderPublished

Ask how to configure an identity provider as code and the answer is usually Terraform. It is often the right answer. Keycloak, authentik and Zitadel all publish Terraform providers that manage OAuth clients, and a team that already plans and applies everything through Terraform has good reasons to keep doing so.

Nauthera has no Terraform provider. Its clients are Kubernetes resources reconciled by an operator, and we chose that model. This article compares the two models for one job, managing the OAuth clients of applications that run on Kubernetes, using the published providers' documentation for the Terraform side. It ends with the cases where Terraform is the better tool.

Two models

Terraform acts when someone runs it. terraform plan reads the current state of the remote objects it manages, compares them with the configuration, and shows the difference; terraform apply makes the change and records the result in a state file (terraform plan). Between runs, nothing happens.

An operator is a controller that runs in the cluster all the time. It watches the resources it owns and the system it configures, and acts on every change to either. For Nauthera, that is the OIDCClient resources in your namespaces on one side and the Nauthera server on the other.

The differences below follow from that one distinction.

1. It notices drift between runs

Change a client in the identity provider's console, and Terraform finds out at the next plan. terraform plan -refresh-only shows the change without proposing to undo it, and a plain apply writes the configuration back over it. If nobody runs a plan for a month, the change is invisible for a month.

Nauthera's operator probes the server once a minute, and every answer carries a revision of the server's configuration. Every 15 seconds the operator checks whether that revision moved; when it has, it lists its clients once and compares each one's revision with the one it last wrote. A client changed elsewhere is marked Drifted on its resource within about a minute, and the operator writes nothing over it until the manifest changes or someone annotates the resource nauthera.io/restore. Undoing a change is a decision, not a side effect of the next apply.

A scheduled plan in CI gets Terraform part of the way there. The difference that remains is where the answer shows up: in a pipeline log, or on the resource the application team already watches.

2. A generated secret never lands in a state file

Terraform's documentation is direct about this: "Terraform stores your state in a plaintext file, which includes any secret values you defined in your configuration" (Manage sensitive data). A secret the identity provider generates is a computed attribute, so it is read back into state too. The Keycloak provider's keycloak_openid_client generates one when client_secret is omitted (docs), and authentik's authentik_provider_oauth2 and Zitadel's zitadel_application_oidc both document their client_secret as a generated, sensitive attribute.

Terraform 1.11 added write-only arguments, which are never stored in state or plan files, and the Keycloak provider offers client_secret_wo from its version 5.3.0 (changelog). That closes the state problem, at the price of supplying the secret yourself, so it is generated and kept somewhere you manage. Getting it into the application's namespace is then one more resource whose value has to stay out of state as well.

With the operator, the manifest only asks for a secret. The server mints it and keeps its digest, and the operator writes the plaintext into a Secret in the application's namespace. No file, pipeline or person holds it on the way; the secrets article follows it end to end.

3. No one outside the cluster holds the admin credential

To apply a client, a Terraform provider authenticates to the identity provider with a credential that may create and change clients, scoped as finely as the provider allows. Whoever can run the pipeline, or change what it applies, can change any client that credential reaches. Giving each team its own boundary means a workspace and a credential per team.

The operator holds one credential: a client certificate the server verifies over mutual TLS. The server grants that identity a list of operations, organizations and namespaces, and refuses anything outside it. Teams never see the credential. They get Kubernetes RBAC instead: the chart's nauthera-app-developer role, bound in a team's namespace, lets the team write OIDCClient and Branding resources there and no other Nauthera kind, and an Organization decides which namespaces may configure it. See Who may change what.

4. It refuses at the API and reports on the object

A Terraform provider validates what its schema knows during plan, and the identity provider refuses the rest during apply, in the pipeline's output. Nauthera's resource definition carries CEL rules that the Kubernetes API server runs on every write, so a public client without PKCE is refused at kubectl apply or in the GitOps sync that tried it (how those rules work).

What only the server can judge comes back on the resource. The Ready condition says Registered, or False with a reason such as Refused, Drifted or Unreachable, and the message names the field. kubectl get, an Argo CD health check and a Flux Kustomization with wait all read the same condition.

5. It follows the application's lifecycle

An OIDCClient can live in the application's own Helm chart. It is created with the app, changed in the same pull request as the code that needs the change, and deleted with it. spec.deletionPolicy says what deletion means: the default Retain keeps the client registered and releases its Secret, so deleting a namespace by mistake cannot take a live credential with it, while Delete removes the client too. A finalizer holds the resource until that is done.

A Terraform-managed client has a lifecycle of its own. Removing an application means removing its client from the Terraform configuration as a separate change, in a separate repository or at least a separate apply.

Where Terraform is the better tool

The comparison is not one-sided. Choose Terraform, or keep it, when:

  • The identity provider is not in your cluster, or you configure a hosted identity service. An operator manages what its own server serves; Terraform reaches anything with an API.
  • One change spans systems. A DNS record, a cloud IAM binding, a database and the client that depends on them can sit in one plan. An operator sees only the cluster.
  • You want a plan before every change. Terraform shows what the provider will do before it does it. With the operator, the review is the manifest's diff in the pull request and kubectl diff; what only the server can check is known after the apply.
  • You need the whole provider surface. The Keycloak provider manages realms, authentication flows, mappers and identity providers. Nauthera's control plane covers six resource kinds, and some client settings, back-channel logout URIs among them, can only be set through dynamic registration today.
  • It is already working. A team running Keycloak with Terraform and write-only secrets has little to gain by switching for client management alone.

The two also combine well. Terraform can build the cluster and install the Nauthera Helm chart and its CRDs, while application teams ship their OIDCClient resources with their apps. The operator page compares the operator with Terraform and Ansible situation by situation, and Changes made outside the operator documents the drift behavior described here.

Hear when the operator ships

One email when the operator ships. No newsletter.

By joining, you agree to our Privacy Policy and Terms.