State backends, backup and restore¶
Default: Kubernetes backend¶
When spec.backendConfig and spec.cloud are unset, the controller writes a
backend_override.tf into the working directory configuring Terraform's native
kubernetes backend:
terraform {
backend "kubernetes" {
secret_suffix = "<object name>"
in_cluster_config = true
namespace = "<object namespace>"
labels = { }
}
}
Consequences:
- State lives gzip-encoded in Secret
tfstate-<workspace>-<name>in the object's namespace (status.stateSecretName). Each workspace gets its own Secret. - Locking uses the Lease
lock-tfstate-<workspace>-<name>; the lock identifier is reported instatus.lock.pendingwhen a run cannot acquire it. - The runner writes the state with its ServiceAccount (
tf-runner), which is why it needs Secrets/Leases permissions in that namespace. - The Secret is visible in the Argo CD resource tree (data redacted) because
it lives next to the Terraform object, but it is not owned by it: deleting
the object keeps the state unless
spec.tfstate.deleteOnDestroyis set.
spec.backendConfig tunes the generated block:
backendConfig:
secretSuffix: shared-network # -> tfstate-default-shared-network
inClusterConfig: true
configPath: "" # kubeconfig path inside the runner (external cluster)
labels:
team: platform # added to the state Secret by the backend
Setting DISABLE_TF_K8S_BACKEND=1 on the controller switches the default to
backend "local" (only useful for tests).
Other backends¶
Any backend block can be supplied verbatim and is placed inside terraform {}:
spec:
backendConfig:
customConfiguration: |
backend "s3" {
bucket = "my-tfstate"
key = "network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "my-tfstate-locks"
}
backendConfigsFrom: # -backend-config key=value pairs from Secrets/ConfigMaps
- kind: Secret
name: s3-backend-credentials
keys: [access_key, secret_key]
Provider/backends credentials can also come from the runner environment
(spec.runner.podTemplate.spec.env|envFrom) or workload identity annotations on
tf-runner.
spec.backendConfig.disable: true writes no backend block at all (state stays
in the runner's working directory and is lost after the run — only for
stateless modules).
Terraform Cloud / Enterprise¶
spec:
cloud:
organization: my-org
hostname: app.terraform.io # optional
workspaces:
name: network # or tags: [network, prod]
cliConfigSecretRef: # Secret with a *.tfrc key holding the credentials block
name: tfc-credentials
spec.cloud disables the Kubernetes backend; state lives in the TFC workspace.
Locks¶
If a run is interrupted the state may stay locked. The controller reports it:
Ready=False LockHeld Terraform Locked with Lock Identifier: f2ab685b-…
status.lock.pending: f2ab685b-…
Unlock by setting (or using the Argo CD Force unlock action):
spec:
tfstate:
forceUnlock: yes # requires lockIdentifier to equal status.lock.pending
lockIdentifier: f2ab685b-…
# or: forceUnlock: auto # unlock whatever is pending — only if nothing else uses the state
lockTimeout: 2m # how long plan waits for the lock before failing
Reset forceUnlock to no afterwards.
Backup and restore (Kubernetes backend)¶
Back up the state Secret like any other Secret (Velero, etc.) or by hand:
kubectl get secret tfstate-default-<name> -n <ns> -o jsonpath='{.data.tfstate}' | base64 -d | gzip -d > terraform.tfstate
Restore:
gzip -k terraform.tfstate
kubectl create secret generic tfstate-default-<name> -n <ns> --from-file=tfstate=terraform.tfstate.gz \
--dry-run=client -o yaml | kubectl apply -f -
Migrating to another backend: change spec.backendConfig; init runs with
-force-copy, so Terraform migrates the state to the new backend on the next
run. Take a backup first.
Working with the state from the CLI¶
Sometimes you need terraform state, terraform import or terraform
taint by hand. The Kubernetes backend works from a laptop too; the only
difference from the runner is in_cluster_config = false, which makes the
backend use your kubeconfig.
-
Suspend the object so the controller does not run concurrently:
kubectl -n team-a patch tf network --type merge -p '{"spec":{"suspend":true}}' -
Point a working copy at the same Secret. Clone the object's source and, in its
path, add an override file (do not commit it):# backend_override.tf terraform { backend "kubernetes" { secret_suffix = "network" # spec.backendConfig.secretSuffix, default: object name namespace = "team-a" in_cluster_config = false # labels = { … } # only if spec.backendConfig.labels is set } }terraform init # or: tofu init — use the same family as status.binary terraform workspace select default # only if spec.workspace is set to something elseYour kubeconfig user needs
get/create/updateon Secrets and Leases in that namespace; theargocd-terraform-controller-runner-roleClusterRole is a convenient reference. Provider credentials must be in your shell as usual. -
Operate:
terraform state list terraform state show aws_s3_bucket.logs terraform import aws_s3_bucket.legacy legacy-bucket-name terraform state rm aws_instance.old terraform force-unlock <lock id> -
Resume:
kubectl -n team-a patch tf network --type merge -p '{"spec":{"suspend":false}}'The next reconcile plans against the modified state. If you changed the state in a way that the configuration does not reflect, that plan will show it — which is the point.
Warning
There is no local copy: the Secret is the state. Anything you do with the
CLI is immediately what the controller sees. Take a backup first (above).
And resist terraform destroy from the CLI unless you also delete or change
the object — otherwise the controller recreates everything on the next run
because the source still declares it. The GitOps way to destroy is
spec.destroy: true or deleting the object with
destroyResourcesOnDeletion; see Destroy and deletion.
Security notes¶
- Enable encryption at rest for Secrets in the cluster: the state contains every attribute of every resource, including sensitive ones.
- Restrict who can read Secrets in namespaces hosting Terraform objects; Argo
CD users with
applications, getsee the Secret's existence but not its data. - The runner only has permissions in its own namespace. Give it cloud credentials with the least privilege the configuration needs.
Plan storage¶
Plans are separate from state: the binary plan is gzip-encoded in Secret(s)
tfplan-<workspace>-<name> (chunked into -0, -1, … when larger than 1 MiB),
with the plan ID in the savedPlan annotation. spec.storeReadablePlan: human
(default) also writes the terraform show text to ConfigMap
tfplan-<workspace>-<name>-readable for the UI; json writes the JSON plan to a
Secret; none stores only the binary plan. Plan objects are owned by the
Terraform object and are deleted with it.