Deploying AWS EKS cluster using Terragrunt

Introduction

Membangun pondasi Kubernetes ala production-grade di AWS, dengan Terraform module yang reusable diorkestrasi oleh Terragrunt. Dari VPC sampai aplikasi yang di-deploy via Helm dan di-expose lewat Application Load Balancer.

  • VPC dedicated (public + private subnet di 2 AZ)
  • EKS control plane + worker node group managed (2× t3.small)
  • Aplikasi contoh (gunawand/learn-devops-1:v1.0) di-deploy via custom Helm chart
  • Opsional: AWS Application Load Balancer lewat Ingress yang didefinisikan di chart

MasalahSolusi
Kubernetes ribet di-bootstrap manualEKS handle control plane (managed)
Terraform state berantakan lintas banyak stackTerragrunt’s dependency + mock_outputs bikin stack DRY
Helm chart “drift” dari state clusterTerraform’s helm_release = GitOps untuk Helm
Production butuh ALB + TLS + path routingAWS Load Balancer Controller menghubungkan K8s Ingress ↔ AWS ALB

Kalau Anda sudah baca tutorial saya sebelumnya tentang Terragrunt untuk VPC/EC2, ini langkah berikutnya yang natural — pola sama, tapi sekarang orchestrasi resource Kubernetes.

                    +-------------------+
                    |      Internet     |
                    | (kubectl client)  |
                    +---------+---------+
                              |
                              | HTTPS (443) — public endpoint
                              | HTTP (80)   — ALB
                    +---------v---------+
                    |   EKS Cluster    |
                    |   Control Plane  |
                    |  (managed by AWS)|
                    +---------+---------+
                              |
              +---------------+---------------+
              |                               |
   +----------v---------+         +-----------v---------+
   |  Private Subnet    |         |  Private Subnet     |
   |  AZ-a              |         |  AZ-b               |
   +----------+---------+         +-----------+---------+
              |                               |
   +----------v---------+         +-----------v---------+
   |  t3.small worker   |         |  t3.small worker    |
   |  (managed node grp)|         |  (managed node grp) |
   +--------------------+         +--------------------+
              |                               |
   +----------v-------------------------------v---------+
   |   VPC (10.20.0.0/16)                               |
   +-------------------------+--------------------------+
                             |
   +-------------------------+--------------------------+
   |  Public subnets (10.20.1.0/24, 10.20.2.0/24)       |
   |  - 1x NAT Gateway (hemat biaya untuk dev)          |
   |  - 1x Internet Gateway                             |
   +----------------------------------------------------+

   Side components (di-deploy sebagai Terragrunt stack):
   ┌──────────────────────────────────────────────┐
   │  AWS Load Balancer Controller                │
   │  - Helm release di namespace kube-system     │
   │  - ServiceAccount dengan IRSA annotation     │
   │  - Watch K8s Ingress → provision ALB         │
   └──────────────────────────────────────────────┘

Struktur Folder

days-5-eks/
├── README.md
├── charts/                                    # Custom Helm chart (versioned di Git)
│   └── learn/
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
│           ├── _helpers.tpl
│           ├── deployment.yaml
│           ├── service.yaml
│           └── ingress.yaml                    # Opsional: ALB Ingress
├── live/                                      # Terragrunt config (env-specific)
│   └── aws/
│       ├── root.hcl                           # Region AWS + generator provider
│       └── dev/
│           ├── vpc/terragrunt.hcl
│           ├── eks/terragrunt.hcl
│           ├── alb-controller/terragrunt.hcl   # LBC + IRSA (cluster-level)
│           └── learn/terragrunt.hcl           # Deploy app via Helm
└── modules/                                   # Terraform module (reusable)
    └── aws/
        ├── vpc/
        ├── eks/
        ├── helm-app/                          # Generic Helm chart deployer
        ├── aws-load-balancer-controller/      # Installer LBC
        └── alb-ingress/                       # Alternatif Ingress via Terraform

Syarat

  • Terraform ≥ 1.5.0
  • Terragrunt ≥ 0.55
  • AWS CLI sudah dikonfigurasi (aws configure)
  • kubectl untuk verifikasi
  • Helm CLI (opsional, untuk validasi chart)
  • AWS IAM permissions: EC2, EKS, IAM, VPC, ELB (full access cukup untuk dev)

Step 1: Root Config (live/aws/root.hcl)

Satu file ini di-share oleh semua Terragrunt stack di bawah live/aws/**. File ini men-generate AWS provider dengan region + default tags yang konsisten.

# Root config untuk semua AWS live stack.
locals {
  aws_region = "ap-southeast-3"
}

# Remote state — DIMATIKAN untuk iterasi pertama.
# Uncomment setelah bootstrap S3 + DynamoDB.
# remote_state {
#   backend = "s3"
#   generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }
#   config = {
#     bucket         = "eks-dev-tf-state-${local.aws_region}"
#     key            = "${path_relative_to_include()}/terraform.tfstate"
#     region         = local.aws_region
#     encrypt        = true
#     dynamodb_table = "eks-dev-tf-locks"
#   }
# }

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOT
provider "aws" {
  region = "${local.aws_region}"
  default_tags {
    tags = {
      ManagedBy = "Terragrunt"
      Repo      = "days-5-eks"
      Project   = "eks-learn"
    }
  }
}
EOT
}

Kenapa pakai generate "provider"?

  • DRY: tulis konfigurasi provider sekali, berlaku untuk semua stack
  • Auto-rewrite kalau root berubah (tidak ada provider.tf yang stale)

Step 2: VPC Module

Module VPC ini generic dan reusable. Ia menerima:

  • List AZ (minimal 2 untuk EKS)
  • CIDR untuk public + private subnet
  • Optional cluster name (menambahkan tag Kubernetes ke subnet)
# modules/aws/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  enable_dns_support   = true
}

resource "aws_subnet" "public" {
  for_each = {
    for idx, cidr in var.public_subnet_cidrs :
    var.azs[idx] => cidr
  }

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value
  availability_zone       = each.key
  map_public_ip_on_launch = true

  tags = merge(
    var.tags,
    { Name = "${var.name}-public-${each.key}", Tier = "public" },
    var.cluster_name != "" ? {
      "kubernetes.io/cluster/${var.cluster_name}" = "shared"
      "kubernetes.io/role/elb"                   = "1"
    } : {}
  )
}

# ... (private subnet, IGW, NAT GW, route table — kode lengkap di repo)

Penting: Tag kubernetes.io/cluster/<name> di subnet wajib ada agar in-tree AWS cloud provider di Kubernetes bisa men-provision load balancer. Kita pass cluster_name hanya setelah EKS cluster ada — tapi Terragrunt handle urutannya secara otomatis lewat dependency.

Stack Terragrunt-nya:

# live/aws/dev/vpc/terragrunt.hcl
include "root" { path = find_in_parent_folders("root.hcl") }
terraform { source = "${get_terragrunt_dir()}/../../../../modules/aws/vpc" }

inputs = {
  name                = "eks-dev-vpc"
  cidr                = "10.20.0.0/16"
  azs                 = ["ap-southeast-3a", "ap-southeast-3b"]
  public_subnet_cidrs = ["10.20.1.0/24", "10.20.2.0/24"]
  private_subnet_cidrs = ["10.20.11.0/24", "10.20.12.0/24"]
  cluster_name        = "eks-dev"   # Untuk tag K8s di subnet
  tags = { Environment = "dev", Project = "eks-learn" }
}

Step 3: EKS Module (Manual, Bukan terraform-aws-modules)

Saya pilih menulis resource EKS secara manual daripada pakai module populer terraform-aws-modules/eks/aws. Alasannya:

  • Lebih edukatif — Anda lihat setiap IAM role, policy attachment, security group
  • Tidak terkunci versi di module pihak ketiga
  • Kontrol penuh atas setiap detail

Module ini membuat:

  • Cluster IAM role + AmazonEKSClusterPolicy
  • Cluster security group (ingress 443 dari VPC CIDR)
  • EKS cluster dengan endpoint public + private
  • Worker node IAM role + 3 policy standar (WorkerNodePolicy, CNIPolicy, ECR-ReadOnly)
  • Managed node group dengan t3.small + AMI AL2023_x86_64
# modules/aws/eks/main.tf (cuplikan)
resource "aws_eks_cluster" "this" {
  name     = var.cluster_name
  version  = var.kubernetes_version
  role_arn = aws_iam_role.cluster.arn

  vpc_config {
    subnet_ids         = var.subnet_ids
    security_group_ids = [aws_security_group.cluster.id]
    endpoint_public_access  = true
    endpoint_private_access = true
    public_access_cidrs     = ["0.0.0.0/0"]
  }

  depends_on = [aws_iam_role_policy_attachment.cluster_policy]
}

resource "aws_eks_node_group" "this" {
  cluster_name    = aws_eks_cluster.this.name
  node_group_name = "${var.cluster_name}-nodes"
  node_role_arn   = aws_iam_role.node.arn
  subnet_ids      = var.subnet_ids

  instance_types = [var.instance_type]
  ami_type       = "AL2023_x86_64"  # AL2 sudah deprecated Nov 2025
  capacity_type  = "ON_DEMAND"

  scaling_config {
    desired_size = var.desired_size
    min_size     = var.min_size
    max_size     = var.max_size
  }

  depends_on = [
    aws_iam_role_policy_attachment.node_worker_policy,
    aws_iam_role_policy_attachment.node_cni_policy,
    aws_iam_role_policy_attachment.node_registry_policy,
  ]
}

Stack Terragrunt-nya pakai dependency "vpc" untuk baca subnet ID:

# live/aws/dev/eks/terragrunt.hcl
include "root" { path = find_in_parent_folders("root.hcl") }
terraform { source = "${get_terragrunt_dir()}/../../../../modules/aws/eks" }

dependency "vpc" {
  config_path = "../vpc"
  mock_outputs = {
    vpc_id                  = "vpc-00000000000000000"
    vpc_cidr                = "10.20.0.0/16"
    private_subnet_ids      = jsonencode(["subnet-aaa", "subnet-bbb"])
    private_subnet_ids_by_az = jsonencode({
      "ap-southeast-3a" = "subnet-aaa"
      "ap-southeast-3b" = "subnet-bbb"
    })
  }
  mock_outputs_allowed_terraform_commands = ["validate", "plan", "destroy"]
}

inputs = {
  cluster_name       = "eks-dev"
  kubernetes_version = "1.32"
  vpc_id             = dependency.vpc.outputs.vpc_id
  vpc_cidr           = dependency.vpc.outputs.vpc_cidr
  subnet_ids         = try(
    jsondecode(dependency.vpc.outputs.private_subnet_ids),
    dependency.vpc.outputs.private_subnet_ids
  )
  instance_type = "t3.small"
  desired_size  = 2
  min_size      = 1
  max_size      = 3
  tags = { Environment = "dev", Project = "eks-learn" }
}

Perhatikan pattern jsonencode / try-jsondecode — ini workaround untuk bug Terragrunt 1.0.x di mana mock_outputs dengan map value gagal di-parse. Dengan encode map sebagai JSON string, kita side-step bug-nya. Saat runtime, map HCL asli di-pass lewat jsondecode.


Step 4: Deploy App dengan Helm + Terraform

Di sinilah bagian menariknya. Kita deploy aplikasi contoh menggunakan:

  • Custom Helm chart (versioned di Git)
  • helm_release resource Terraform (supaya deploy-an = GitOps)

Helm Chart (charts/learn/)

charts/learn/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── _helpers.tpl
    ├── deployment.yaml
    ├── service.yaml
    └── ingress.yaml        # Kondisional

Chart ini mengikuti konvensi Helm 3 (apiVersion: v2) dengan label yang proper lewat _helpers.tpl.

Module Helm-nya generic — menerima chart path dan values map apapun:

# modules/aws/helm-app/main.tf (cuplikan)
data "aws_eks_cluster" "this" {
  name = var.cluster_name
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args        = ["eks", "get-token", "--cluster-name", var.cluster_name, "--region", var.aws_region]
  }
}

provider "helm" {
  kubernetes { /* sama seperti di atas */ }
}

resource "helm_release" "this" {
  name             = var.release_name
  chart            = var.chart_path
  namespace        = var.namespace
  create_namespace = true

  values = [yamlencode(var.values)]
  wait   = true
  cleanup_on_fail = true
}

Insight penting: Kita pakai aws eks get-token via exec auth plugin — tidak perlu setup kubeconfig manual. Terraform authenticate ke EKS pakai IAM credential yang sama dengan AWS provider.

Stack Terragrunt menyambungkan semuanya:

# live/aws/dev/learn/terragrunt.hcl (cuplikan)
dependency "eks" {
  config_path = "../eks"
  mock_outputs = { cluster_name = "eks-dev" }
  mock_outputs_allowed_terraform_commands = ["validate", "plan", "destroy"]
}

inputs = {
  cluster_name = dependency.eks.outputs.cluster_name
  chart_path   = "${get_terragrunt_dir()}/../../../../charts/learn"
  release_name = "learn"
  namespace    = "be"

  values = {
    replicaCount = 2
    image = {
      repository = "gunawand/learn-devops-1"
      tag        = "v1.0"
    }
    containerPort = 80
    service = { type = "ClusterIP", port = 80 }
    # ... resources, ingress, dll
  }
}

Step 5 (Opsional): Expose via ALB dengan AWS Load Balancer Controller

Untuk setup yang beneran public-facing, Anda butuh ALB (bukan CLB default). Ini butuh AWS Load Balancer Controller (LBC) — sebuah pod controller yang watch Kubernetes Ingress dan men-provision ALB.

Kenapa LBC? Kenapa tidak cukup type: LoadBalancer?

FiturIn-tree type: LoadBalancerAWS LBC
Yang dibuatClassic Load Balancer (legacy)Application / Network LB
Path-based routing❌✅
TLS termination❌✅
Host-based routing❌✅
Banyak Ingress → 1 ALB❌✅ via IngressGroup
Production-ready❌✅

Install LBC + IRSA

LBC butuh IAM permissions untuk panggil AWS API. Best practice-nya adalah IRSA (IAM Role for Service Account):

# modules/aws/aws-load-balancer-controller/main.tf (cuplikan)

# 1. Ambil OIDC issuer dari cluster
data "aws_eks_cluster" "this" { name = var.cluster_name }

# 2. Daftarkan OIDC issuer sebagai IAM OIDC provider
data "tls_certificate" "eks_oidc" {
  url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "eks" {
  url             = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint]
}

# 3. IAM role dengan trust policy (hanya SA LBC yang boleh assume)
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.eks.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")}:sub"
      values   = ["system:serviceaccount:${var.namespace}:${var.service_account_name}"]
    }
  }
}

resource "aws_iam_role" "alb_controller" {
  name               = "${var.iam_role_name_prefix}-${var.cluster_name}"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

# 4. Attach AWS-managed policy berisi permission LBC
resource "aws_iam_role_policy_attachment" "alb_controller" {
  role       = aws_iam_role.alb_controller.name
  policy_arn = "arn:aws:iam::aws:policy/AWSLoadBalancerControllerIAMPolicy"
}

# 5. Install via Helm
resource "helm_release" "aws_load_balancer_controller" {
  name       = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  chart      = "aws-load-balancer-controller"
  namespace  = var.namespace

  set { name = "clusterName", value = var.cluster_name }
  set { name = "region", value = var.aws_region }
  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = aws_iam_role.alb_controller.arn  # ← keajaiban IRSA
  }
}

Mendefinisikan Ingress (di Helm chart)

Pendekatan paling bersih adalah menyertakan Ingress di dalam chart itu sendiri, di-toggle via values.yaml:

# charts/learn/values.yaml
ingress:
  enabled: true
  className: alb
  hosts:
    - host: ""
      paths: [{ path: /, pathType: Prefix }]
  alb:
    scheme: internet-facing
    targetType: ip
    healthCheckPath: /
    groupName: ""  # Kosong = ALB sendiri per chart. Set nama sama di beberapa chart untuk share.
# charts/learn/templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "learn.fullname" . }}
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: {{ .Values.ingress.alb.scheme }}
    alb.ingress.kubernetes.io/target-type: {{ .Values.ingress.alb.targetType }}
    alb.ingress.kubernetes.io/healthcheck-path: {{ .Values.ingress.alb.healthCheckPath }}
    {{- if .Values.ingress.alb.groupName }}
    alb.ingress.kubernetes.io/group.name: {{ .Values.ingress.alb.groupName }}
    {{- end }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "learn.fullname" $ }}
                port: { number: $.Values.service.port }
          {{- end }}
    {{- end }}
{{- end }}

LBC watch Ingress ini, lihat annotation-nya, lalu provision ALB dengan konfigurasi yang cocok.

Urutan deploymentnya adalah sebagai berikut

# 1. VPC — paling cepat
cd live/aws/dev/vpc && terragrunt init && terragrunt apply -auto-approve

# 2. EKS — lambat (10-15 menit untuk control plane)
cd ../eks && terragrunt init && terragrunt apply -auto-approve

# 3. AWS Load Balancer Controller (sekali per cluster)
cd ../alb-controller && terragrunt init && terragrunt apply -auto-approve

# 4. Deploy app + Ingress + ALB
cd ../learn && terragrunt init && terragrunt apply -auto-approve

# 5. Ambil kubeconfig & verifikasi
cd ../eks && terragrunt output -raw update_kubeconfig_command | bash

kubectl get nodes
# NAME                            STATUS   ROLES    AGE   VERSION
# ip-10-20-11-...compute.internal  Ready    <none>   5m    v1.32.x-eks-yyyyy
# ip-10-20-12-...compute.internal  Ready    <none>   5m    v1.32.x-eks-yyyyy

kubectl get pods,svc,ingress -n be
# NAME                          READY   STATUS    RESTARTS   AGE
# pod/learn-7c8d4f9b9c-xxxxx    1/1     Running   0          1m
# pod/learn-7c8d4f9b9c-yyyyy    1/1     Running   0          1m
#
# NAME           TYPE        CLUSTER-IP     PORT(S)
# service/learn  ClusterIP   172.20.x.x     80/TCP
#
# NAME           CLASS   HOSTS   ADDRESS
# ingress/learn  alb     *       k8s-be-learn-xxx.ap-southeast-3.elb.amazonaws.com

curl http://k8s-be-learn-xxx.ap-southeast-3.elb.amazonaws.com

Leave a Reply

Your email address will not be published. Required fields are marked *