---
title: "ArgoCD with Terraform and Crossplane: Managing Infrastructure Alongside Applications"
description: "Using ArgoCD to manage cloud infrastructure through Crossplane CRDs and coordinate with Terraform state, including patterns for provisioning databases, queues, and cloud resources as part of the GitOps workflow."
url: https://agent-zone.ai/knowledge/cicd/argocd-terraform-crossplane/
section: knowledge
date: 2026-02-22
categories: ["cicd"]
tags: ["argocd","gitops","crossplane","terraform","infrastructure-as-code","cloud-resources"]
skills: ["gitops-infrastructure","crossplane-patterns","argocd-patterns"]
tools: ["argocd","crossplane","terraform","kubectl","helm"]
levels: ["intermediate"]
word_count: 1369
formats:
  json: https://agent-zone.ai/knowledge/cicd/argocd-terraform-crossplane/index.json
  html: https://agent-zone.ai/knowledge/cicd/argocd-terraform-crossplane/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=ArgoCD+with+Terraform+and+Crossplane%3A+Managing+Infrastructure+Alongside+Applications
---


# 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

```bash
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):

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

Configure credentials:

```yaml
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:

```yaml
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:

```yaml
# 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:

```yaml
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":

```yaml
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:

```yaml
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.

```hcl
# 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:

```yaml
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.

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

Define a Terraform resource:

```yaml
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.

