ArgoCD with Terraform and Crossplane#

Applications need infrastructure – databases, queues, caches, object storage, DNS records, certificates. In a GitOps workflow managed by ArgoCD, there are two approaches to provisioning that infrastructure: Crossplane (Kubernetes-native) and Terraform (external). Each has different strengths and integration patterns with ArgoCD.

Crossplane: Infrastructure as Kubernetes CRDs#

Crossplane extends Kubernetes with CRDs that represent cloud resources. An RDS instance becomes a YAML manifest. A GCS bucket becomes a YAML manifest. ArgoCD manages these manifests exactly like it manages Deployments and Services.

Why Crossplane Fits ArgoCD#

Crossplane resources are Kubernetes resources. ArgoCD already knows how to sync, diff, and health-check Kubernetes resources. There is no special integration needed. You commit a Crossplane manifest to Git, ArgoCD syncs it, Crossplane provisions the cloud resource.

Git repo: RDS manifest → ArgoCD syncs to cluster → Crossplane provisions RDS on AWS
Git repo: Deployment manifest → ArgoCD syncs to cluster → Kubernetes schedules pods

Both follow the same GitOps loop. Infrastructure and applications are managed identically.

Installing Crossplane#

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace

Install a provider (AWS example):

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1

Configure credentials:

apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-credentials
      key: creds

Provisioning a Database with Crossplane + ArgoCD#

Commit this to your GitOps repo:

apiVersion: rds.aws.upbound.io/v1beta2
kind: Instance
metadata:
  name: my-app-db
  namespace: my-app
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  forProvider:
    allocatedStorage: 20
    engine: postgres
    engineVersion: "15"
    instanceClass: db.t3.micro
    dbName: myapp
    masterUsername: admin
    masterPasswordSecretRef:
      name: db-master-password
      namespace: my-app
      key: password
    region: us-east-1
    skipFinalSnapshot: true
  writeConnectionSecretToRef:
    name: db-connection
    namespace: my-app

ArgoCD syncs this manifest. Crossplane sees the CRD and provisions an actual RDS instance on AWS. The connection details (hostname, port, credentials) are written to the Kubernetes Secret db-connection, which the application Deployment can reference.

The sync wave annotation (-1) ensures the database is provisioned before the application Deployment (wave 0 or higher).

Custom Health Checks for Crossplane Resources#

Crossplane resources use a Ready condition that ArgoCD does not check by default. Add a custom health check:

# In argocd-cm ConfigMap
data:
  resource.customizations.health.rds.aws.upbound.io_Instance: |
    hs = {}
    if obj.status ~= nil and obj.status.conditions ~= nil then
      for i, condition in ipairs(obj.status.conditions) do
        if condition.type == "Ready" then
          if condition.status == "True" then
            hs.status = "Healthy"
            hs.message = "RDS instance is ready"
            return hs
          else
            hs.status = "Progressing"
            hs.message = condition.message or "Provisioning RDS instance"
            return hs
          end
        end
      end
    end
    hs.status = "Progressing"
    hs.message = "Waiting for RDS instance"
    return hs

A generic Crossplane health check that works for most resources:

data:
  resource.customizations.health.*.upbound.io_*: |
    hs = {}
    if obj.status ~= nil and obj.status.conditions ~= nil then
      for i, condition in ipairs(obj.status.conditions) do
        if condition.type == "Ready" then
          if condition.status == "True" then
            hs.status = "Healthy"
            hs.message = condition.message or "Resource is ready"
            return hs
          elseif condition.reason == "ReconcileError" or condition.reason == "ReconcilePaused" then
            hs.status = "Degraded"
            hs.message = condition.message or "Resource error"
            return hs
          end
        end
      end
    end
    hs.status = "Progressing"
    hs.message = "Waiting for resource"
    return hs

Compositions: Reusable Infrastructure Templates#

Crossplane Compositions let you define reusable infrastructure blueprints. Teams request infrastructure using a simple claim; the Composition handles the complex provisioning.

Define a Composition for a “production database”:

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XDatabase
    plural: xdatabases
  claimNames:
    kind: Database
    plural: databases
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:
                  type: string
                  enum: ["small", "medium", "large"]
                engine:
                  type: string
                  default: "postgres"

Application teams commit a simple claim:

apiVersion: platform.example.com/v1alpha1
kind: Database
metadata:
  name: my-app-db
  namespace: my-app
spec:
  size: small
  engine: postgres

The Composition translates size: small into db.t3.micro with 20GB storage, configures backups, sets up security groups, and provisions the RDS instance. Application teams do not need to know AWS specifics.

Terraform: External State, ArgoCD Coordination#

Terraform manages infrastructure through its own state file, plan/apply cycle, and providers. It does not run inside Kubernetes and its state is not a Kubernetes resource. This makes ArgoCD integration less natural but still workable.

Pattern 1: Terraform Provisions, ArgoCD Consumes#

The simplest pattern separates responsibilities completely:

Terraform manages:
  - VPCs, subnets, security groups
  - EKS/AKS/GKE clusters
  - RDS/Cloud SQL instances
  - IAM roles and policies
  - DNS zones

ArgoCD manages:
  - Everything inside Kubernetes
  - Applications, services, ingress
  - In-cluster operators and controllers

Terraform outputs (database hostnames, IAM role ARNs, etc.) are passed to ArgoCD applications through:

  1. Kubernetes Secrets: Terraform writes outputs to Secrets that ArgoCD applications reference.
  2. ExternalSecrets: Terraform writes outputs to AWS Secrets Manager; ESO syncs them to the cluster.
  3. ConfigMaps: For non-sensitive outputs like hostnames and ARNs.
# Terraform creates a Secret with the RDS endpoint
resource "kubernetes_secret" "db_connection" {
  metadata {
    name      = "db-connection"
    namespace = "my-app"
  }
  data = {
    host     = aws_db_instance.main.address
    port     = tostring(aws_db_instance.main.port)
    database = aws_db_instance.main.db_name
  }
}

The ArgoCD Application references this Secret without managing it. Add the Secret to ArgoCD’s ignored resources so it does not try to prune it:

spec:
  ignoreDifferences:
    - group: ""
      kind: Secret
      name: db-connection
      jsonPointers:
        - /data

Pattern 2: Terraform Controller for Kubernetes#

The Terraform Controller (tf-controller) runs Terraform inside Kubernetes, storing plans and state as CRDs. ArgoCD manages the Terraform CRD like any other manifest.

helm install tf-controller tf-controller/tf-controller \
  --namespace flux-system \
  --create-namespace

Define a Terraform resource:

apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
  name: vpc
  namespace: infrastructure
  annotations:
    argocd.argoproj.io/sync-wave: "-2"
spec:
  path: ./terraform/vpc
  sourceRef:
    kind: GitRepository
    name: infrastructure
    namespace: flux-system
  interval: 10m
  approvePlan: auto
  writeOutputsToSecret:
    name: vpc-outputs

ArgoCD syncs this CRD. The Terraform Controller runs terraform plan and terraform apply. Outputs are written to a Kubernetes Secret that other applications can consume.

This pattern is less mature than Crossplane and introduces a dependency on Flux’s GitRepository CRD, which is awkward alongside ArgoCD. It works but adds operational complexity.

Pattern 3: CI Pipeline Runs Terraform, ArgoCD Manages Apps#

The most common production pattern separates the tooling completely:

CI Pipeline (GitHub Actions, Jenkins, etc.):
  1. terraform plan
  2. Manual approval (for production)
  3. terraform apply
  4. Write outputs to Secret Manager or Git
  5. Commit updated connection details to GitOps repo

ArgoCD:
  1. Detects Git change (new connection details)
  2. Syncs applications with updated infrastructure references

This keeps Terraform’s mature plan/approve/apply workflow intact while letting ArgoCD handle the Kubernetes deployment side.

Crossplane vs Terraform: Decision Framework#

Factor Crossplane Terraform
GitOps integration Native (Kubernetes CRDs) Requires glue (controllers, CI, or manual)
ArgoCD experience First-class (just another manifest) Indirect
State management Kubernetes (etcd) S3/GCS/Azure Blob + lock table
Drift detection Continuous (controller reconciliation) Only on terraform plan
Learning curve Kubernetes + Crossplane CRDs HCL + provider docs
Ecosystem maturity Growing, but fewer providers than Terraform Massive, covers nearly everything
Multi-cloud Yes (one CRD per provider) Yes (one provider per cloud)
Existing Terraform investment Would need migration Keep using it

Use Crossplane when you want infrastructure to be truly GitOps-native, managed by ArgoCD alongside applications, with continuous drift detection.

Use Terraform when you have existing Terraform modules, need providers Crossplane does not support, or prefer Terraform’s explicit plan/approve workflow.

Use both when Terraform manages the foundational layer (VPCs, clusters, IAM) and Crossplane manages application-level infrastructure (databases, caches, queues) that lives closer to the application lifecycle.

Common Mistakes#

  1. Not adding Crossplane health checks to ArgoCD. Without custom health checks, ArgoCD marks Crossplane resources as Progressing indefinitely. Add Lua health checks for every Crossplane resource type you use.
  2. Putting Crossplane resources in the same sync wave as the application. Cloud resources take minutes to provision. The application starts before the database is ready. Use sync waves to ensure infrastructure is healthy before applications deploy.
  3. Letting ArgoCD prune Terraform-managed Secrets. If Terraform creates a Secret and ArgoCD’s application has prune enabled, ArgoCD deletes the Secret because it is not in Git. Use ignoreDifferences or exclude Terraform-managed resources from ArgoCD’s scope.
  4. Ignoring Crossplane resource costs. A Composition that provisions an RDS instance, ElastiCache cluster, and S3 bucket is easy to create with a one-line claim. It is also easy to forget those resources cost money. Add cost annotations or documentation to Compositions.
  5. Running Terraform Controller alongside ArgoCD without clear ownership boundaries. Both tools reconcile state. If they overlap on the same resources, they fight. Define clear boundaries: ArgoCD manages applications, Terraform Controller manages infrastructure, and they share data through Secrets.