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 podsBoth 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-namespaceInstall 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:v1Configure credentials:
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-credentials
key: credsProvisioning 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-appArgoCD 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 hsA 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 hsCompositions: 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: postgresThe 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 controllersTerraform outputs (database hostnames, IAM role ARNs, etc.) are passed to ArgoCD applications through:
- Kubernetes Secrets: Terraform writes outputs to Secrets that ArgoCD applications reference.
- ExternalSecrets: Terraform writes outputs to AWS Secrets Manager; ESO syncs them to the cluster.
- 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:
- /dataPattern 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-namespaceDefine 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-outputsArgoCD 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 referencesThis 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#
- Not adding Crossplane health checks to ArgoCD. Without custom health checks, ArgoCD marks Crossplane resources as
Progressingindefinitely. Add Lua health checks for every Crossplane resource type you use. - 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.
- 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
ignoreDifferencesor exclude Terraform-managed resources from ArgoCD’s scope. - 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.
- 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.