Terraform Should Not Deploy Into EKS. ArgoCD Should.

A concise case for letting Terraform manage AWS infrastructure while ArgoCD owns Kubernetes workloads inside EKS.

terraformargocdeksawsgitopsplatform-engineering

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:

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:

Terraform should not manage:

In short: Terraform talks to AWS APIs. ArgoCD talks to Kubernetes APIs.

Terraform owns the AWS API plane, ArgoCD owns the Kubernetes API planerepo-opsterraform/ + clusters/AWS APIsKubernetes API — inside the clusterTerraformArgoCDplan & applypull & reconcileVPC · Subnets · NAT · Security GroupsEKS cluster · Managed node groupsIAM · OIDC · IRSA · ECR · RDS · KMSNamespaces · ingress-nginx · cert-managerExternalDNS · External Secrets · RolloutsDeployments · Services · image tagsAPI boundaryno helm_release, no kubernetes_manifest — Terraform never crosses this line
One repository, two engines, two API planes. Both read desired state from Git; neither writes into the other’s plane.

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:

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.

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:

Avoid it if:

Conclusion

Terraform should stop at the AWS API boundary. ArgoCD should start at the Kubernetes API boundary.