Terraform Should Not Deploy Into EKS. ArgoCD Should.
A concise case for letting Terraform manage AWS infrastructure while ArgoCD owns Kubernetes workloads inside EKS.
Many teams use Terraform not only to provision AWS infrastructure, but also to install Helm charts, create namespaces, and apply Kubernetes manifests inside EKS. That is the fastest way to blur the boundary between infrastructure and platform.
The better model is less convenient at first: separate the application repository from the ops repository, let Terraform manage AWS only, and let ArgoCD own everything that lives inside the cluster. This is the boundary ArgoCD’s own best practices argue for — a separate Git repository for manifests rather than config mixed into application source.
Context
Consider an internal platform on AWS with multiple services, one EKS cluster, and dev, staging, and prod environments. The platform team is small, but it is expected to deliver repeatable changes without becoming the release bottleneck. In that setup, the real problem is not only shipping software. The real problem is defining who is allowed to change what.
When Terraform creates the VPC, the EKS cluster, the node groups, the IAM roles, and then in the same workflow also installs ingress controllers, cert-manager, External DNS, and application releases, the platform develops two structural problems. First, it mixes different lifecycle speeds. Second, it introduces multiple writers for the same operational state.
The Obvious Model
The model most teams try first is simple: one repository, one pipeline, Terraform for AWS, and Terraform for Kubernetes through helm_release, kubernetes_manifest, or Kubernetes providers. It is easy to defend because everything looks centralized and one apply appears to control the whole system.
That model works only when the cluster is mostly static, in-cluster components change rarely, and the team accepts that application delivery is tied to Terraform state transitions. The problem starts when the cluster becomes a platform. At that point Terraform is no longer just provisioning infrastructure. It is trying to perform continuous delivery.
The Correct Separation
The Decision
A more reliable architecture looks like this:
- Application repo: source code, tests, Dockerfile, build pipeline.
- Ops repo: Terraform for AWS plus GitOps manifests for Kubernetes.
- Terraform: AWS resources and the EKS perimeter only.
- ArgoCD: everything that exists inside the cluster.
The key insight is simple: AWS and EKS do not share the same lifecycle. VPCs change slowly. IAM roles change carefully. Kubernetes manifests, platform add-ons, and image tags change constantly. Forcing both layers through the same engine creates unnecessary coupling, and it throws away the two things the split actually buys: an audit log per layer, and access boundaries that match the team that owns them.
Repository Layout
A practical structure looks like this:
repo-app/
├── src/
├── Dockerfile
├── charts/ # optional, if the chart lives with the app
└── .github/workflows/
└── build.yaml
repo-ops/
├── terraform/
│ ├── aws/
│ │ ├── vpc/
│ │ ├── eks/
│ │ ├── iam/
│ │ └── ecr/
│ └── envs/
│ ├── dev/
│ ├── staging/
│ └── prod/
├── clusters/
│ ├── dev/
│ │ ├── argocd/
│ │ ├── platform/
│ │ └── apps/
│ ├── staging/
│ └── prod/
└── bootstrap/
└── root-app.yaml
This solves an organizational problem before it solves a technical one. Application teams merge into the application repo. Platform engineers govern the ops repo. Infrastructure changes and deployment changes are reviewed independently, rolled back independently, and owned independently.
What Terraform Should Manage
Terraform should stop at the AWS boundary:
- VPC, subnets, route tables, NAT, security groups.
- EKS cluster and managed node groups.
- ECR.
- IAM, OIDC provider, and IRSA roles.
- ACM, Route53, S3, RDS, ElastiCache, SQS, SNS, and KMS.
- Outputs needed to bootstrap the platform.
Terraform should not manage:
- Kubernetes namespaces.
- Deployments, Services, Ingresses, ConfigMaps, or Secrets.
- Platform add-ons deployed as charts or manifests, such as ingress-nginx, ExternalDNS, cert-manager, External Secrets, or Argo Rollouts.
- Application image versions.
In short: Terraform talks to AWS APIs. ArgoCD talks to Kubernetes APIs.
Terraform Example
This example shows the correct boundary. Terraform creates the cluster and the AWS-side dependencies, but it does not use Kubernetes or Helm providers to deploy into EKS.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "platform-prod"
cidr = "10.20.0.0/16"
azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
private_subnets = ["10.20.1.0/24", "10.20.2.0/24", "10.20.3.0/24"]
public_subnets = ["10.20.101.0/24", "10.20.102.0/24", "10.20.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = false
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.0"
name = "platform-prod"
kubernetes_version = "1.34"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
enable_irsa = true
eks_managed_node_groups = {
general = {
instance_types = ["t3.large"]
min_size = 3
max_size = 10
desired_size = 4
}
}
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
resource "aws_ecr_repository" "api_gateway" {
name = "platform/api-gateway"
image_tag_mutability = "IMMUTABLE"
}
output "cluster_name" {
value = module.eks.cluster_name
}
output "cluster_endpoint" {
value = module.eks.cluster_endpoint
}
output "oidc_provider_arn" {
value = module.eks.oidc_provider_arn
}
The important detail is not what is present. It is what is missing. There is no helm_release, no kubernetes_manifest, and no hidden kubectl apply disguised as infrastructure as code.
ArgoCD Owns the Cluster
ArgoCD should manage everything inside the cluster:
- namespace bootstrap;
- platform add-ons;
- shared configuration;
- applications;
- image promotion across environments;
- drift detection and self-healing.
A cluster should have one declarative writer. If Terraform writes to Kubernetes and ArgoCD writes to Kubernetes, the platform already has a control-plane conflict.
Here is an example Application for a platform component:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-dns
namespace: argocd
spec:
project: platform
source:
repoURL: https://github.com/acme/repo-ops.git
targetRevision: main
path: clusters/prod/platform/external-dns
destination:
server: https://kubernetes.default.svc
namespace: external-dns
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
The architectural message is clear. Terraform creates the cluster and the AWS-side IAM roles needed by external-dns. ArgoCD installs external-dns in the cluster and keeps it aligned with Git. Each tool owns its layer.
Bootstrapping the Bootstrapper
There is an obvious objection to all of this: if Terraform must not install charts, who installs ArgoCD?
The honest answer is that exactly one thing is applied out of band, once per cluster. A bootstrap step — a helm upgrade --install argocd from a short-lived runner, or the plain install manifest — puts ArgoCD in the cluster, and immediately after that a single root Application is applied:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/repo-ops.git
targetRevision: main
path: clusters/prod
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
That is bootstrap/root-app.yaml. From this point the app-of-apps pattern takes over: the root Application discovers every other Application under clusters/prod, including the one that manages ArgoCD itself. ArgoCD then owns its own upgrades, and the bootstrap credentials are never needed again.
This is a real cost, and it is worth naming rather than hiding: the model trades one permanent write path from CI for one temporary write path at cluster creation. A single manual step at day zero is a much smaller problem than cluster credentials sitting in a pipeline forever.
The Correct Deployment Flow
Application delivery should not apply infrastructure. It should update desired state in the ops repository.
name: Build and Promote
on:
push:
branches: [main]
permissions:
id-token: write # federate into AWS, no long-lived keys
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.short_sha }}
steps:
- uses: actions/checkout@v5
- name: Compute image tag
id: meta
run: echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/ci-ecr-push
aws-region: eu-west-1
- name: Log in to ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
IMAGE_TAG: ${{ steps.meta.outputs.short_sha }}
run: |
docker build -t "$REGISTRY/platform/api-gateway:$IMAGE_TAG" .
docker push "$REGISTRY/platform/api-gateway:$IMAGE_TAG"
promote:
runs-on: ubuntu-latest
needs: build
env:
IMAGE_TAG: ${{ needs.build.outputs.image_tag }}
steps:
- name: Checkout ops repo
uses: actions/checkout@v5
with:
repository: acme/repo-ops
token: ${{ secrets.OPS_REPO_TOKEN }}
path: repo-ops
- name: Update prod image tag
run: |
cd repo-ops/clusters/prod/apps/api-gateway
yq -i '(.images[] | select(.name == "platform/api-gateway")).newTag = strenv(IMAGE_TAG)' kustomization.yaml
- name: Commit and push
run: |
cd repo-ops
git config user.name "github-actions"
git config user.email "actions@github.com"
git add .
git diff --cached --quiet && exit 0
git commit -m "chore(api-gateway): promote $IMAGE_TAG"
git push
Two details are worth stealing. The image tag is computed once and passed between jobs as an output, because env: values in a workflow file are not shell-expanded — IMAGE_TAG: ${GITHUB_SHA::7} would be written into the manifest verbatim. And the promotion commit is guarded by git diff --cached --quiet, so a re-run of the same SHA is a no-op instead of an empty-commit failure.
The pipeline ends here. It does not touch the cluster. It does not need kubeconfig. It does not need administrative permissions on EKS. It updates Git and stops. ArgoCD observes the commit and reconciles the state. This is also why the manifests live in a second repository: a pipeline that writes config back into the repo that triggered it is a pipeline that triggers itself.
If that argument is convincing on its own, I made the longer version of it in Removing kubectl from CI Pipelines.
What Breaks Without This Separation
The first common failure is double ownership.
- Terraform installs
ingress-nginxwithhelm_release. - ArgoCD also manages the
ingress-nginxnamespace and some chart values. - A harmless change produces
OutOfSyncresources. - The next
terraform applystarts a reconciliation fight.
The root cause is not the tool. The root cause is that two declarative systems both believe they are the source of truth.
The second failure is subtler: application delivery inherits infrastructure risk. If a service only needs an image-tag change but the release workflow also runs terraform apply, then a simple deployment can be blocked by drift in subnets, IAM, or node groups. This is not a fringe opinion either: the Kubernetes provider documentation itself warns against stacking Kubernetes resources on top of the cluster resource that creates them, and recommends separate states to keep provider configuration from depending on resources in the same apply.
The third failure is operational. Application rollback becomes too heavy. If going back to the previous version requires reasoning about Terraform state, chart versions, variables, and dependency order, the platform is no longer a delivery system. It is a puzzle.
Trade-offs
| Gain | Loss |
|---|---|
| Clear boundary between infrastructure and platform | One extra repository |
| No cluster credentials in CI | One extra GitOps step |
| Simple, auditable rollbacks | Sync is not instant |
| Clear ownership between app and platform teams | Requires discipline on boundaries |
| Kubernetes drift handled by ArgoCD | Bootstrap is more explicit |
This split is worth it when the cluster is a shared platform, platform add-ons change more often than AWS infrastructure, and the team wants to reduce the blast radius of deployments. It is the wrong model when the system is tiny, the cluster is nearly static, and GitOps would add more ceremony than value.
When to Use It
Use this model if you have:
- multiple teams deploying into the same cluster;
- platform add-ons that evolve independently of AWS infrastructure;
- a need for clear auditability and rollback;
- multiple environments with controlled promotion;
- a requirement to remove cluster access from CI.
Avoid it if:
- the environment is temporary or just a proof of concept;
- ownership between app teams and platform teams is still unclear;
- the team is intentionally using Terraform as an application release engine.
Conclusion
Terraform should stop at the AWS API boundary. ArgoCD should start at the Kubernetes API boundary.