Terraform Operator: running Terraform apply from inside Kubernetes
Published: 2026-04-24
Yandex Cloud infrastructure (VMs, managed Kubernetes clusters, VPCs) is managed by Terraform. Running terraform apply from a GitLab CI job works, but requires passing credentials as variables and managing state externally. The GalleyBytes terraform-operator runs Terraform as a Kubernetes workload — the state, credentials, and execution all live in the cluster.
The operator
GalleyBytes terraform-operator defines a Terraform CRD. Each object describes a Terraform module to run: the source git repo, the backend config, and how often to reconcile.
It's deployed from custom/apps/terraform-operator/ via a HelmRelease on the infra cluster.
Install:
bashkubectl apply -f https://raw.githubusercontent.com/GalleyBytes/terraform-operator/master/deploy/bundles/crd-bundle.yaml
Or as a HelmRelease via Flux:
yamlapiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: terraform-operator
namespace: terraform-operator
spec:
chart:
spec:
chart: terraform-operator
sourceRef:
kind: HelmRepository
name: galleybytes
version: ">=0.1.0"
interval: 1h
The Terraform CRD object
yamlapiVersion: tf.galleybytes.com/v1beta1
kind: Terraform
metadata:
name: infra-terraform
namespace: terraform-operator
spec:
terraformVersion: "1.5.5"
terraformModule:
source: "git::ssh://git@gitlab.example.com/docker/k8s/infra.git//terraform/infra?ref=main"
scmAuthMethods:
- host: gitlab.example.com
git:
ssh:
sshKeySecretRef:
name: flux-system
namespace: flux-system
key: identity
backend: |
terraform {
backend "local" {}
}
storageClassName: local-path
keepLatestPodsOnly: true
writeOutputsToStatus: true
env:
- name: YC_TOKEN
valueFrom:
secretKeyRef:
name: yc-token
key: YC_TOKEN
source — the module comes from the same infra git repo, path terraform/infra, branch main. The operator clones it before each run.
scmAuthMethods — reuses the flux-system SSH key secret that Flux creates during bootstrap. No separate credential management needed.
backend: local — Terraform state is stored on a PVC (created by the operator using storageClassName: local-path). Not in S3 or Terraform Cloud. For production use, switch to backend "s3" pointing to an object storage bucket for state durability.
writeOutputsToStatus: true — Terraform outputs (cluster endpoint IDs, VPC IDs) are written to the Terraform object's .status field, making them available to other Kubernetes controllers via kubectl get or ExternalSecrets.
Vault token for Terraform
The Yandex Cloud provider requires a token. Stored as a SealedSecret:
yamlapiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: yc-token
namespace: terraform-operator
spec:
encryptedData:
YC_TOKEN: AgBx...
Referenced in the Terraform object as an env var for the runner pod.
RBAC
The operator needs RBAC to create pods and PVCs:
yamlapiVersion: v1
kind: ServiceAccount
metadata:
name: terraform-operator
namespace: terraform-operator
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: terraform-operator
rules:
- apiGroups: ["tf.galleybytes.com"]
resources: ["*"]
verbs: ["*"]
- apiGroups: [""]
resources: ["pods", "pods/log", "persistentvolumeclaims", "secrets", "serviceaccounts"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: terraform-operator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: terraform-operator
subjects:
- kind: ServiceAccount
name: terraform-operator
namespace: terraform-operator
Terraform directory structure
terraform/
├── infra/ ← infra cluster YC resources (VMs, LB, VPC)
├── dev/ ← dev cluster
├── test/ ← test cluster
├── sre/ ← sre YC cluster
├── loadgds/ ← loadgds YC cluster
└── demo/ ← demo YC cluster
Each directory is an independent Terraform root module. A separate Terraform CRD object manages each one independently.
Reconcile behavior
The operator runs terraform plan and terraform apply on a schedule (default: every 5 minutes). If the plan shows no changes — no-op. If it detects drift (from manual edits or new resources in .tf files) — it applies automatically.
keepLatestPodsOnly: true — the runner pod from each reconcile is kept for log inspection, but only the most recent one. Old pods are cleaned up automatically.
To trigger an immediate reconcile:
bashkubectl annotate terraform infra-terraform \
-n terraform-operator \
tf.galleybytes.com/sync="true" \
--overwrite \
--context=infra-k8s
Viewing status
bash# Terraform object status
kubectl get terraform -n terraform-operator --context=infra-k8s
# Detailed status with conditions
kubectl describe terraform infra-terraform -n terraform-operator --context=infra-k8s
# Runner pod logs for the last apply
kubectl logs \
-n terraform-operator \
-l terraform.galleybytes.com/name=infra-terraform \
--context=infra-k8s
# Terraform outputs written to status
kubectl get terraform infra-terraform \
-n terraform-operator \
-o jsonpath='{.status.outputs}' \
--context=infra-k8s | jq .
Comparison: CI job vs operator
| GitLab CI | Terraform Operator | |
|---|---|---|
| Triggered by | git push / schedule | Kubernetes reconcile loop |
| State storage | External (S3 / HTTP) | PVC in cluster |
| Credentials | CI variables | Kubernetes secrets |
| Drift detection | Manual / scheduled pipeline | Automatic (every N minutes) |
| Audit trail | CI job logs | Pod logs + .status history |
| Multi-cluster | CI project per cluster | One Terraform CRD per module |
The operator is better suited for infrastructure that rarely changes but needs drift detection. CI pipelines are better for infrastructure that changes on every PR with approval gates.
Troubleshooting
Runner pod stuck in Init state — check operator logs:
bashkubectl logs -n terraform-operator -l app=terraform-operator
git clone fails — SSH key not found or wrong namespace. Verify:
bashkubectl get secret flux-system -n flux-system -o yaml
State lock error — a previous runner pod died mid-apply and left a lock. Delete the state lock file in the PVC or force-unlock:
bash# Exec into a debug pod with the PVC mounted
terraform force-unlock <LOCK_ID>
Summary
- Terraform Operator runs
terraform applyas a Kubernetes workload — state, credentials, and execution all live in the cluster - Workspaces (
WorkspaceCRD) define the git source, Vault credentials, and variable overrides per-environment - State is stored in a PVC mounted to the runner pod, not in a remote backend — keep the PVC on a reliable StorageClass
- A failed runner pod mid-apply leaves a state lock; use
terraform force-unlockto recover - Operator fits scenarios where you want GitOps-native Terraform without giving CI runners direct access to cloud credentials