Skip to main content
RunBook Academy

Git, CI/CD & GitOpsLXXXVIII · Infrastructure GitOpsFoundations

Terraform and the Argo CD application controller — manifests as Git state

Advanced⏱ ~24 mingitterraform

What you'll learn

  • Explain how the terraform-controller renders Terraform manifests stored in Git as Kubernetes resources
  • Identify the role of the Terraform and Workspace custom resources
  • Distinguish a controller-driven Terraform loop from a CI-driven Terraform loop
  • Recognise the operational boundary: state lives where, secrets come from where, drift is detected how

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

The argoproj-labs/terraform-controller extends Argo CD’s GitOps model to Terraform. Where Atlantis runs as an external service and reconciles per pull request, the terraform-controller runs inside the cluster as a Kubernetes operator and reconciles continuously, on a tick, against a custom resource that references a Terraform module. The cluster is the runner; the cloud is the target; the GitOps model is the same.

The model in one sentence: declare a Terraform module in Git, declare a Terraform custom resource in the cluster pointing at that module, and let the controller apply the module on every drift detection. The four OpenGitOps principles are satisfied: declarative (HCL), versioned (Git ref), pulled (the controller reads the ref), reconciled (the controller loops).

flowchart LR
    A["Git module ref"] --> B["Terraform CR"]
    B --> C["Controller"]
    C -->|"terraform init"| D["Module"]
    C -->|"plan"| E["Diff"]
    E -->|"approve"| F["Apply"]
    F -->|"write"| G["Output CR"]
    C -->|"state"| H["Workspace CR"]
    H -->|"lock"| I["Backend"]

The controller reads the Terraform resource, fetches the module at the declared ref, runs terraform init against a workspace declared by the Workspace custom resource, runs a plan, computes the diff, applies on approval or auto-approval, and writes the outputs to a Kubernetes secret or status field. The state file is held in the workspace, which can be a Kubernetes secret, an S3 bucket, or a remote backend; the controller does not care.

The Terraform and Workspace CRDs

The controller defines two custom resources. The Workspace holds configuration for a Terraform backend and a credential source; the Terraform holds a reference to a module, a workspace to use, and approval settings. The split matters because a single workspace can be reused across many Terraform resources - a shared state file with multiple modules writing to it - or one workspace per resource for isolation.

apiVersion: terraformcontroller.codes/v1alpha1
kind: Terraform
metadata:
  name: production-network
spec:
  sourceRef:
    module:
      source: git::https://github.com/acme/modules.git//network
      version: v1.4.2
  workspace: prod
  destroyResourcesOnDeletion: true
  approval: Auto

The sourceRef.module block is the GitOps declaration: a module address, a version (a Git ref or tag), and the path inside the repository. The controller pulls the module at the declared version, treats it as the desired state, and reconciles against the cloud. The approval field controls whether the apply needs an out-of-band annotation (Auto allows apply, Manual requires a human to set spec.approved=true).

The Workspace carries the backend configuration and the credentials:

apiVersion: terraformcontroller.codes/v1alpha1
kind: Workspace
metadata:
  name: prod
spec:
  module:
    source: git::https://github.com/acme/modules.git//network
  terraformVersion: 1.6.6
  values:
    region: us-east-1
  credentials:
    - name: aws-creds
      source: Secret
      secretRef:
        name: aws-credentials

The credentials block is where the boundary between cluster and cloud is enforced. The controller reads the secret at apply time, mounts it into the Terraform job, and the job uses it to talk to the cloud provider. The secret never leaves the cluster as plaintext, but it does leave the cluster as the temporary credentials in a Terraform process.

Auto-approval versus manual approval

The approval field is the production boundary. Auto produces a continuous-reconciler loop: the controller plans, detects drift or new commits, and applies. This is the GitOps-shaped answer - the controller is the reconciler - but it removes the human-in-the-loop approval that Atlantis provides by default.

Manual produces a controller-driven plan with a human-gated apply. The plan is recorded in the Terraform resource’s status, and the apply only runs after a human sets spec.approved=true. The boundary is the Kubernetes API; the audit trail is the API server log.

sequenceDiagram
    participant G as Git module
    participant T as Terraform CR
    participant C as Controller
    participant W as Workspace
    participant K as Cloud
    G->>C: new ref detected
    C->>W: acquire workspace
    C->>T: plan result in status
    alt approval = Manual
        T->>C: spec.approved=true
    end
    C->>K: terraform apply
    C->>T: outputs in status

The choice between Auto and Manual is not a configuration detail; it is a policy decision. Auto is appropriate for low-risk modules (DNS records, log buckets, S3 lifecycle policies). Manual is required for high-risk modules (IAM, network rules, anything that changes authentication or authorization).

Where secrets come from

Secrets in the controller model come from Kubernetes secrets, referenced by the Workspace’s credentials block. The controller does not encrypt them; it relies on etcd encryption at rest and RBAC on the secret resource. The boundary is the same boundary as the rest of Kubernetes: the secret is encrypted in etcd, decrypted by the API server, and mounted into the Terraform job as an environment variable or a file.

This is the operational difference from Atlantis. Atlantis fetches secrets from the CI runner’s environment or a secret-manager lookup; the controller fetches from Kubernetes. The trade-off is consistency (Kubernetes secrets are uniform across all controllers) versus flexibility (Atlantis can integrate with Vault, AWS Secrets Manager, or any backend the CI runner can authenticate against).

Production discipline

  1. Choose approval mode per module. Auto for low-risk (DNS, log retention, tagging). Manual for IAM, network rules, anything that changes authentication or authorization.
  2. Pin the module ref. A version: main reference means the controller reconciles against whatever the module’s default branch points at, which can change without a commit in the controller’s repository. Pin to a tag or a commit SHA.
  3. State backend per workspace. A shared state file across modules is a coordination point. The controller serialises plans per workspace; two modules writing to one state file are two modules that step on each other.

Cross-course references

  • This course, Part LXXII (GitOps Foundations) - the four principles in their canonical Kubernetes form, which the controller extends to Terraform.
  • Terraform for Production Sysadmins - Parts IX-XII (State) cover the state backend in depth; the controller model uses the same backends.
  • Kubernetes for Production Sysadmins - Parts on CustomResourceDefinitions and operators cover the controller pattern that terraform-controller implements.

Quiz

Knowledge check · 4 questions

  1. Q1. What is the role of the Terraform custom resource in the terraform-controller model?

  2. Q2. The terraform-controller applies resources automatically on every drift detection when approval is set to Auto, regardless of whether a human has reviewed the change.

  3. Q3. The terraform-controller replaces Atlantis for all Terraform GitOps workflows.

  4. Q4. Diagnose why the controller has applied a change to production that the security team did not authorise.

    A team adopted terraform-controller with Auto approval for a module that manages production IAM roles. A developer opens a PR against the module repository, merging a change to the trust policy. The module's CI builds and tags a new version. The controller detects the new ref, plans, applies. The security team is not in the module's codeowners and only finds out when CloudTrail shows the trust policy change.

Passing score: 75%. Answers are checked in this browser.