Terraform Lesson 62 of 89

Terraform on AWS EKS: ALB Ingress, ACM SSL, ExternalDNS & Route 53 Automation

The single most satisfying thing you can build on EKS is the one that makes a demo click: you apply a Kubernetes Ingress and, a minute later, https://app.example.com answers with a valid padlock — no console clicks, no cut-and-pasted certificate, no manual DNS entry. That outcome is not one service; it is four moving parts wired at their seams. The AWS Load Balancer Controller turns an Ingress with ingressClassName: alb into a real, internet-facing Application Load Balancer. ACM issues the TLS certificate that terminates on that ALB’s :443 listener. ExternalDNS watches the Ingress’s hostname and writes the matching Route 53 record so the world can resolve it. And Route 53 itself holds the hosted zone, delegated from your domain. Get the seams right and the reader types one URL and it works; get them wrong and you meet the four failures every EKS engineer hits once — an Ingress whose ADDRESS stays stubbornly empty, an ALB that’s up but a hostname that resolves nowhere, a listener with no certificate, and a 404 that traces back to a target-type nobody thought about.

This lesson builds that whole flow with Terraform, and it is deliberate about which plane owns what. Terraform owns the platform: the hosted zone, the ACM certificate and its DNS validation, the IRSA role that lets ExternalDNS talk to Route 53 with least privilege, and the helm_release that installs ExternalDNS. Kubernetes objects — a Deployment, a Service, and an Ingress — express the app’s intent, and the controllers reconcile that intent into AWS resources. You will run it end to end: terraform init → plan → apply, then kubectl get ingress, dig, and curl to prove each layer independently, and finally terraform destroy with the one ordering rule that stops an ALB from leaking. This is an Expert-tier lesson: it assumes you already know core Terraform (HCL, providers, variables, state, for_each, IRSA in outline) and that an EKS cluster already exists with an IAM OIDC provider and the AWS Load Balancer Controller installed. Those two prerequisites are lessons of their own — Terraform on EKS: OIDC, IRSA & IAM roles for service accounts and Terraform on EKS: the AWS Load Balancer Controller — and this lesson takes them as read, pins hashicorp/aws ~> 5.60, and focuses on the Ingress-to-URL flow.

What you’ll build

The scenario is the front door for a containerised web app running on EKS. The app is an ordinary Deployment behind a Service; it must be reachable at https://app.kv-demo.example; every request must be HTTPS with a valid, auto-renewing certificate; the DNS record must be created and updated automatically as the Ingress changes; and — because ALBs cost real money — several apps should be able to share one ALB instead of each spinning up its own. In console-and-kubectl terms that is: create an ACM cert, click through DNS validation, note the ARN, hand-write it into an annotation, apply the Ingress, wait for the ALB, copy the ALB’s DNS name, and create a Route 53 alias by hand — a chain of manual steps that drifts the moment anyone touches it. In Terraform plus two controllers it is one directory you can read, plan (so a reviewer sees the cert ARN and the host before they ship), and destroy in a single command.

Concretely, terraform apply will stand up: a public aws_route53_zone; an aws_acm_certificate for kv-demo.example (+ *.kv-demo.example) validated by the canonical for_each pattern and gated by aws_acm_certificate_validation; an IRSA role (aws_iam_role + a Route 53-scoped policy) for ExternalDNS; a helm_release that installs ExternalDNS wired to that role; and a sample app as three typed Kubernetes resources — kubernetes_deployment_v1, kubernetes_service_v1, and kubernetes_ingress_v1 carrying the ALB annotations. The AWS Load Balancer Controller (already running from the prerequisite) sees the Ingress and provisions the ALB; ExternalDNS sees the same Ingress and writes the Route 53 record; the result is a working https://app.kv-demo.example.

Here is how the pieces map to who provisions them — keep this table open, it is the spine of the lesson and the reason the demo splits across two providers:

Component Plane / who provisions it Terraform resource / object Runtime actor
Hosted zone AWS (Terraform) aws_route53_zone Route 53
TLS certificate AWS (Terraform) aws_acm_certificate (+ _validation) ACM
IRSA role for ExternalDNS AWS (Terraform) aws_iam_role + aws_iam_role_policy STS / OIDC
ExternalDNS install Kubernetes (Terraform via Helm) helm_release ExternalDNS pod
App workload Kubernetes (Terraform) kubernetes_deployment_v1 / _service_v1 kubelet / pods
Ingress (intent) Kubernetes (Terraform) kubernetes_ingress_v1 controllers reconcile it
ALB (actual) Provisioned by the controller — (no TF resource; the ALB is a side-effect) AWS LB Controller
DNS record (actual) Provisioned by ExternalDNS — (no TF resource; ExternalDNS writes it) ExternalDNS

The two rows with an em-dash are the whole trick and the reason this lesson exists: the ALB and the Route 53 record are not Terraform resources. They are created by controllers in reaction to the Ingress. Terraform declares the Ingress and the platform the controllers need; the controllers do the rest. That is powerful (a kubectl apply in any namespace can get a public HTTPS URL) and it is exactly where the surprises live (Terraform’s destroy doesn’t know about the ALB, so ordering matters — more on that in cleanup).

Why Terraform for the platform rather than eksctl, raw kubectl, or the console? Because the platform is long-lived, security-sensitive, and change-controlled — the profile Terraform fits — while the app belongs to the fast, GitOps-friendly Kubernetes plane:

Approach Provisions IRSA + ACM + zone Installs ExternalDNS Reviewable Verdict
Console + kubectl Manual clicks, no record kubectl apply a raw manifest No Fine to learn; drifts instantly as source of truth
eksctl Some (IRSA, addons) via flags Add-on/manifest Weak Great for bootstrapping a cluster; not for app-layer lifecycle
Helm only No (you supply the role ARN by hand) Yes (the chart) Partially Perfect for the chart; can’t make the IAM/ACM/zone
Terraform (aws+helm+kubernetes) Yes Yes (helm_release) Yes (plan) Best fit: one language for IRSA + ACM + zone + Helm + the Ingress

The one honest caveat, same as any DNS/TLS build: domain delegation lives partly outside Terraform. Your zone answers to the world only once your registrar points the domain’s name servers at Route 53, and ACM’s DNS validation — plus ExternalDNS’s whole job — depends on that delegation resolving publicly. Half the traps in this lesson trace back to that boundary. Let’s build the pieces.

The ALB Ingress model: one Ingress, one ALB

A Kubernetes Ingress is a declarative request for L7 HTTP routing: “expose these hosts and paths to these Services.” Ingress on its own does nothing — it needs an ingress controller to realise it. On EKS the AWS-native controller is the AWS Load Balancer Controller, and when it sees an Ingress whose ingressClassName is alb, it provisions a real Application Load Balancer, creates listeners and target groups, wires the target group to your pods, and writes the ALB’s DNS name back into the Ingress’s status. Everything about how that ALB is built is expressed as annotations on the Ingress — the annotations are the API.

The controller only acts on Ingresses it owns, which is decided by the IngressClass. You create one IngressClass named alb whose controller is ingress.k8s.aws/alb, and every Ingress that wants an ALB sets ingressClassName: alb:

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb
spec:
  controller: ingress.k8s.aws/alb        # the AWS Load Balancer Controller claims these

IngressClass vs the older kubernetes.io/ingress.class annotation, and the optional parameters CRD:

Field Where Purpose Note
spec.controller IngressClass Which controller owns the class Must be ingress.k8s.aws/alb for this controller
ingressClassName Ingress spec Binds an Ingress to a class Preferred; replaces the legacy annotation
kubernetes.io/ingress.class Ingress annotation Legacy class selector Still honoured; don’t set both
IngressClassParams CRD (elbv2.k8s.aws) Class-wide defaults (scheme, group, tags, subnets) Reference via spec.parameters on the IngressClass
spec.parameters IngressClass Points at an IngressClassParams object Lets you set org defaults once, not per-Ingress

Now the Ingress itself. This is the object the reader ultimately applys, and it is worth reading as YAML before we express it in HCL, because the annotations are more legible here:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: demo
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing            # public ALB
    alb.ingress.kubernetes.io/target-type: ip                    # register pod IPs directly
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'                # bounce HTTP → HTTPS
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-south-1:1111…:certificate/abcd
    alb.ingress.kubernetes.io/group.name: kv-shared              # share one ALB (cost)
    alb.ingress.kubernetes.io/healthcheck-path: /
spec:
  ingressClassName: alb
  rules:
    - host: app.kv-demo.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

The annotations that actually matter

The controller supports dozens of annotations; a working HTTPS Ingress needs a small, specific set. This is the reference table to keep — every row is load-bearing at least once in a real build:

Annotation (alb.ingress.kubernetes.io/…) Example value What it controls Why it matters
scheme internet-facing Public vs private ALB internal for private; needs the right tagged subnets
target-type ip ALB registers pod IPs vs node NodePorts ip (VPC CNI) is the modern default; instance needs NodePort Services
listen-ports [{"HTTP":80},{"HTTPS":443}] Which listeners the ALB opens Must include 443 for TLS and 80 for the redirect
ssl-redirect 443 Adds a rule that 301s HTTP → HTTPS Requires an 80 listener to redirect from
certificate-arn arn:aws:acm:… The ACM cert on the :443 listener Omit to let the controller auto-discover by host
ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 TLS/cipher floor on the HTTPS listener Pin a modern policy; drops legacy ciphers
group.name kv-shared Merge this Ingress onto a shared ALB The cost pattern (next section)
group.order 10 Rule priority within the group Lower evaluated first; -10001000, default 0
healthcheck-path /healthz Target-group health-check URL Wrong path ⇒ all targets unhealthy ⇒ 503
healthcheck-port traffic-port Port the health check hits Default follows the target port
success-codes 200-399 Health-check matcher Widen if your health page 301s
backend-protocol HTTP ALB→pod protocol HTTPS for end-to-end TLS
subnets subnet-a,subnet-b Explicit ALB subnets Usually auto-discovered by subnet tags
security-groups sg-… Attach specific SGs Else the controller manages one
wafv2-acl-arn arn:aws:wafv2:… Attach a WAF web ACL Edge protection at L7
load-balancer-attributes idle_timeout.timeout_seconds=60 ALB attributes Idle timeout, access logs, etc.
tags team=web,env=demo Cost/ownership tags on the ALB Tag everything the controller makes

Three of these decide whether the demo works at all. target-type: ip tells the ALB to register pod IPs directly (via the VPC CNI) — with it, an ordinary ClusterIP Service is enough; without it (instance mode) you must expose a NodePort Service or every target reads unhealthy. listen-ports must contain 443 or there is no HTTPS listener for the certificate to live on; and if you want ssl-redirect, it must also contain 80 so there’s a plain-HTTP listener to redirect from. ssl-redirect: '443' is the one-line way to force TLS: the controller injects a rule that returns an HTTP 301 to https:// before the request ever reaches a target.

How the three TLS-related knobs behave together:

Setting Value Effect If missing
listen-ports has 443 [{"HTTPS":443}] ALB opens an HTTPS listener No TLS endpoint at all
certificate-arn validated ACM ARN That cert terminates TLS on :443 Controller tries host auto-discovery
ssl-redirect 443 HTTP :80 → 301 https:// HTTP is served in cleartext (or 404s)
ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 TLS 1.2/1.3 floor Falls back to the ALB default policy

Path and host routing

The rules block is standard Kubernetes Ingress: match on host and path, forward to a Service. The controller turns each rule into an ALB listener rule with conditions. pathType is the field people trip on:

pathType Matches Example Use
Prefix Path prefix on /-segments /api matches /api, /api/v1 The everyday default
Exact The exact path only /health matches only /health Single endpoints
ImplementationSpecific Controller-defined (ALB path patterns) /img/* ALB-native wildcards

Multiple host blocks on one Ingress become multiple listener rules on the same ALB; multiple path entries under a host route by URL. This is how one ALB fronts app.example.com/ and api.example.com/v1 — and, with group.name, how different Ingresses in different namespaces land on that same ALB.

IngressGroup: sharing one ALB across Ingresses (the cost pattern)

An ALB is not free — it bills per hour plus per LCU (Load Balancer Capacity Unit) whether or not it carries traffic. The naive one-Ingress-one-ALB model means ten apps cost ten ALBs. The IngressGroup feature collapses that: every Ingress carrying the same alb.ingress.kubernetes.io/group.name is merged by the controller into a single ALB, with each Ingress contributing its own listener rules. Ten apps, one ALB, one bill.

# app-a (namespace: team-a)
metadata:
  annotations:
    alb.ingress.kubernetes.io/group.name: kv-shared
    alb.ingress.kubernetes.io/group.order: '10'
---
# app-b (namespace: team-b) — lands on the SAME ALB
metadata:
  annotations:
    alb.ingress.kubernetes.io/group.name: kv-shared
    alb.ingress.kubernetes.io/group.order: '20'

The IngressGroup fields and their behaviour:

Field Set via Meaning Trap
group.name Annotation on each Ingress Ingresses sharing this string share one ALB Cluster-wide namespace; pick a unique, owned name
group.order Annotation (-10001000) Rule evaluation priority in the group Duplicate orders ⇒ non-deterministic rule order
Group membership Presence of group.name Add/remove by editing the annotation Removing the last member deletes the ALB
Cross-namespace Any namespace Members can span namespaces Security: anyone who knows the name can attach
Explicit vs implicit Named vs unnamed group Unnamed Ingresses each get their own ALB Omit group.name for a dedicated ALB

The cost math is the reason platform teams standardise on groups:

Pattern ALBs Rough monthly base (ap-south-1) When to use
One ALB per Ingress N ~₹1,600 × N Strict isolation, per-team billing, different SGs/WAF
One shared ALB (group) 1 ~₹1,600 total Many small apps, dev/test, cost-sensitive platforms
A few groups (prod/stage/public/internal) 2–4 ~₹1,600 × groups The pragmatic middle: isolate by trust/scheme

There is a real security note buried in that table. Because group membership is just a shared string and can span namespaces, any Ingress that sets your group.name joins your ALB — including its listener rules and its cert. Treat the group name as semi-privileged: use IngressClassParams to pin a group per class, restrict who can create Ingresses (RBAC), and don’t mix trust boundaries (public and internal) in one group. The flip side — removing the last Ingress from a group deletes the ALB — is exactly the cleanup-ordering issue we return to later.

ACM SSL: the certificate for the listener

The HTTPS listener needs a certificate, and on AWS that means ACM — the same free, auto-renewing, DNS-validated certificate you provision for any ALB or CloudFront distribution. The full mechanics (the for_each over domain_validation_options, the aws_acm_certificate_validation gate, the us-east-1 rule for CloudFront) are covered in depth in Terraform on AWS: Route 53 DNS & ACM SSL certificates; here we need only the parts that touch the Ingress, and one rule that is easy to get wrong on EKS: the certificate for an ALB must live in the ALB’s own Region, not us-east-1. us-east-1 is the CloudFront rule; an ALB is a regional service, so its cert is regional. If your cluster is in ap-south-1, the cert is in ap-south-1.

The cert itself is the standard DNS-validated pattern, created in the same directory:

resource "aws_acm_certificate" "app" {
  domain_name               = var.domain_name              # kv-demo.example
  subject_alternative_names = ["*.${var.domain_name}"]     # covers app.kv-demo.example
  validation_method         = "DNS"
  lifecycle { create_before_destroy = true }
  tags = local.tags
}

resource "aws_route53_record" "cert_validation" {
  for_each = { for dvo in aws_acm_certificate.app.domain_validation_options :
    dvo.domain_name => { name = dvo.resource_record_name, record = dvo.resource_record_value, type = dvo.resource_record_type } }
  allow_overwrite = true
  zone_id         = aws_route53_zone.primary.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
}

resource "aws_acm_certificate_validation" "app" {
  certificate_arn         = aws_acm_certificate.app.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

The interesting choice on EKS is how the certificate reaches the listener. There are two ways, and knowing both saves an afternoon:

Method How Pros Cons
Explicit certificate-arn Set alb.ingress.kubernetes.io/certificate-arn to the validated ARN Deterministic; the exact cert you meant; easy to review You must plumb the ARN into the annotation
Host auto-discovery Omit the annotation; controller finds an ACM cert in-Region whose domain matches the Ingress host (SNI) Zero plumbing; add a host and it “just works” Ambiguous if multiple certs match; silent if none do

Auto-discovery feels magical and is fine for a stable single-cert setup, but for Infrastructure-as-Code the explicit ARN wins: it makes the plan show precisely which certificate ships, and it fails loudly (not silently) if the cert isn’t there. The clean pattern is to wire the annotation to the validated ARN — aws_acm_certificate_validation.app.certificate_arn, never the raw aws_acm_certificate.app.arn — so Terraform only writes the annotation once ACM reports ISSUED. That ordering is why the Ingress in the demo depends_on the validation resource. Add the ssl-policy annotation alongside to pin TLS 1.2/1.3, and ssl-redirect: '443' to force HTTP callers up to TLS.

ExternalDNS: Route 53 records, automatically

The ALB is up, the cert is attached — but app.kv-demo.example still resolves to nothing, because nothing has created the DNS record. You could create an aws_route53_record alias by hand and point it at the ALB — except the ALB’s DNS name isn’t known until the controller makes it, so you’d be chasing a value Terraform doesn’t own. ExternalDNS solves this the Kubernetes-native way: it runs as a pod, watches Ingress and Service objects, reads their hostnames, and creates/updates the matching Route 53 records automatically. Change the Ingress host, and the record follows. Delete the Ingress, and (depending on policy) the record goes too.

ExternalDNS source What it reads Record it creates
ingress spec.rules[].host + spec.tls[].hosts; the ALB name from status.loadBalancer Alias A record → the ALB (or CNAME with --aws-prefer-cname)
service (LoadBalancer) external-dns.alpha.kubernetes.io/hostname annotation; the LB address A/CNAME → the NLB/ELB
service (headless) Pod hostnames A records per endpoint
crd / gateway-* DNSEndpoint CRD / Gateway API Arbitrary records

For an ALB, ExternalDNS is smart: it recognises the …elb.amazonaws.com hostname, looks up the ALB’s canonical hosted-zone, and writes an alias A record (free to resolve, follows the ALB) rather than a plain CNAME. That is exactly the record you’d have hand-crafted — created for you, kept in sync.

Installing ExternalDNS with helm_release + IRSA

ExternalDNS needs one thing from AWS: permission to change records in your hosted zone. The correct, least-privilege way to grant that on EKS is IRSA — an IAM role the ExternalDNS pod assumes through the cluster’s OIDC provider, with a policy scoped to your zone only. (The mechanics of OIDC federation and the trust policy are the subject of the IRSA lesson; here we apply them.)

First the role and its trust — the pod’s ServiceAccount (external-dns in namespace external-dns) is federated to the role via the OIDC sub claim:

data "aws_iam_policy_document" "externaldns_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [var.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${var.oidc_provider_url}:sub"
      values   = ["system:serviceaccount:external-dns:external-dns"]
    }
    condition {
      test     = "StringEquals"
      variable = "${var.oidc_provider_url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "externaldns" {
  name               = "kv-externaldns-irsa"
  assume_role_policy = data.aws_iam_policy_document.externaldns_trust.json
  tags               = local.tags
}

Now the permissions policy — and this is where “least privilege to the zone” is won or lost. ExternalDNS needs exactly two grants: change records in your zone (scoped to the specific hosted-zone ARN), and list zones/records (which Route 53 does not allow to be resource-scoped, so it must be *):

data "aws_iam_policy_document" "externaldns" {
  statement {
    sid       = "ChangeThisZoneOnly"
    effect    = "Allow"
    actions   = ["route53:ChangeResourceRecordSets"]
    resources = ["arn:aws:route53:::hostedzone/${aws_route53_zone.primary.zone_id}"]  # ← scoped!
  }
  statement {
    sid       = "ListAcrossZones"
    effect    = "Allow"
    actions   = ["route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ListTagsForResources"]
    resources = ["*"]     # these Route 53 list actions can't be resource-scoped
  }
}

resource "aws_iam_role_policy" "externaldns" {
  name   = "route53-externaldns"
  role   = aws_iam_role.externaldns.id
  policy = data.aws_iam_policy_document.externaldns.json
}

The policy, broken down — memorise this shape, it’s the least-privilege ExternalDNS role:

Statement Actions Resource Why
Change (scoped) route53:ChangeResourceRecordSets arn:aws:route53:::hostedzone/<ZONE_ID> The write path — restrict it to your zone, not *
List (unscoped) route53:ListHostedZones, route53:ListResourceRecordSets * Route 53 rejects resource-level scoping on these; * is required
Tags (optional) route53:ListTagsForResources * Only if you filter zones by tag (--aws-zone-tags)

The common but sloppy version grants ChangeResourceRecordSets on hostedzone/* — every zone in the account. Scoping it to hostedzone/${zone_id} means a bug or a compromise in ExternalDNS can only touch the one zone you gave it. That is the difference between a least-privilege role and a blast radius.

Now install the chart, wiring the ServiceAccount to the role via the eks.amazonaws.com/role-arn annotation:

resource "helm_release" "externaldns" {
  name             = "external-dns"
  namespace        = "external-dns"
  create_namespace = true
  repository       = "https://kubernetes-sigs.github.io/external-dns/"
  chart            = "external-dns"
  version          = "1.15.0"                 # pin the chart; check for the current release

  values = [yamlencode({
    provider = "aws"                          # newer charts also accept provider = { name = "aws" }
    sources  = ["ingress", "service"]
    policy   = "upsert-only"                  # ⚠️ never deletes records; see the deletion note
    registry = "txt"
    txtOwnerId    = var.cluster_name          # ownership fingerprint in the TXT record
    domainFilters = [var.domain_name]         # only touch this zone's domain
    aws = {
      region   = var.region
      zoneType = "public"
    }
    interval = "1m"
    serviceAccount = {
      create = true
      name   = "external-dns"                 # must match the IRSA trust `sub`
      annotations = {
        "eks.amazonaws.com/role-arn" = aws_iam_role.externaldns.arn
      }
    }
  })]

  depends_on = [aws_iam_role_policy.externaldns]
}

The Helm values that matter, and what each does:

Value Example Effect If wrong
provider aws Use the Route 53 provider Wrong provider ⇒ no AWS calls
sources [ingress, service] Which objects to watch Miss ingress ⇒ no records from Ingresses
domainFilters [kv-demo.example] Restrict to zones for these domains Too broad ⇒ touches unrelated zones; too narrow ⇒ silent no-op
policy upsert-only Create/update but never delete sync deletes on source removal (see below)
registry txt Track ownership via companion TXT records Without it, ExternalDNS can clobber foreign records
txtOwnerId kv-demo-eks This instance’s ownership tag Shared/blank id ⇒ two clusters fight over records
aws.region ap-south-1 Region for API calls Cross-region latency/errors
aws.zoneType public Public vs private zones private for internal split-horizon
serviceAccount.name external-dns SA the pod runs as Mismatch with IRSA subAccessDenied
serviceAccount.annotations eks.amazonaws.com/role-arn: … Binds the SA to the IRSA role Missing ⇒ pod uses node role, usually denied
interval 1m Reconcile loop period Longer ⇒ slower record updates

Ownership, the TXT registry, and the deletion trap

Two ExternalDNS instances pointed at the same zone would trample each other — so ExternalDNS uses a TXT registry: alongside every A record it manages, it writes a companion TXT record encoding heritage=external-dns,external-dns/owner=<txtOwnerId>. Before touching a record, ExternalDNS checks the TXT: if the owner doesn’t match, it leaves it alone. This is what lets many clusters safely share one zone — and why a unique txtOwnerId per cluster is non-negotiable. Newer versions prefix the TXT with the record type (e.g. a-app…, cname-app…) so a name can carry several record types; the details are configurable via --txt-prefix/--txt-suffix.

The policy value is the one with a ⚠️ on it:

policy Creates Updates Deletes Use
sync ✅ (when the source disappears) Tidy zones; but a deleted Ingress drops its DNS
upsert-only ❌ never Safe default; may leave stale records
create-only Append-only; you clean up by hand

The trap: with policy: sync, deleting an Ingress (a routine kubectl delete or a terraform destroy) deletes its Route 53 record — which is correct until it’s your production hostname vanishing because someone tore down the wrong namespace. The TXT registry means ExternalDNS will only delete records it owns, so it can’t nuke a foreign record — but it can absolutely delete the one it made for your prod Ingress. upsert-only refuses to delete anything: safer against accidents, at the price of stale records you prune manually. This lesson uses upsert-only; choose sync only when you genuinely want DNS to be a pure reflection of cluster state and you trust your deletion discipline.

The Route 53 hosted zone

Everything above assumes a hosted zone delegated to your domain. The zone is a one-liner in Terraform, and the delegation is the same manual/registrar step covered exhaustively in the Route 53 & ACM lesson — copy the four name_servers into your registrar (or use a Route 53-registered domain that auto-delegates). Until delegation resolves publicly, neither ACM validation nor ExternalDNS can work:

resource "aws_route53_zone" "primary" {
  name = var.domain_name
  tags = local.tags
}

output "name_servers" {
  description = "Delegate these four at your registrar (skip if the domain is Route 53-registered)."
  value       = aws_route53_zone.primary.name_servers
}

If the zone already exists (created in the Route 53 lesson or by a platform team), swap the resource for a data source and pass the id — the ExternalDNS policy and the cert validation both take zone_id, so nothing else changes:

data "aws_route53_zone" "primary" {
  name         = var.domain_name
  private_zone = false
}
# then reference data.aws_route53_zone.primary.zone_id everywhere

The zone arguments you actually touch here (the full DNS/records/routing-policy surface is in the Route 53 lesson):

Argument Purpose Note in this build
name The domain the zone is authoritative for Must match domainFilters and the cert domain_name
tags Cost/ownership Zones are cheap; tag anyway
force_destroy Allow destroy with stray records ExternalDNS-made records count as stray to Terraform — see cleanup
(data source) Look up an existing zone Use when the zone is owned elsewhere

Hands-on: build it with Terraform

Now the centerpiece — a complete directory you copy, apply, and watch turn an Ingress into a working HTTPS URL. It assumes an existing EKS cluster (call it kv-eks) with an IAM OIDC provider and the AWS Load Balancer Controller already installed (the two prerequisite lessons). You pass the cluster name and OIDC details in; this directory builds the zone, the cert, the ExternalDNS IRSA role, the ExternalDNS install, and the sample app.

Left-to-right EKS architecture: a Terraform + EKS plane provisioning IRSA, ACM and the hosted zone; a Kubernetes Ingress with ingressClassName alb and an ACM cert on its 443 listener; the AWS Load Balancer Controller and ExternalDNS (with a least-privilege IRSA role) as the two reconciling controllers; an internet-facing ALB and a Route 53 alias record; and the app pods behind a health-checked target group. Numbered badges mark the alb ingress class, the ACM cert on the listener, the IngressGroup sharing one ALB, the IRSA role scoped to the zone, ExternalDNS writing the Route 53 record, and the upsert-only deletion policy.

Read it left→right: Terraform stands up the platform (IRSA, ACM, the zone) and applies the Kubernetes objects; the Ingress (ingressClassName: alb, ACM cert on the :443 listener) is reconciled by two Terraform-installed controllers — the AWS Load Balancer Controller provisions one ALB (shared across Ingresses via group.name) that routes to the app pods, while ExternalDNS, using a least-privilege IRSA role, writes the Route 53 record so the host resolves to that ALB. The six badges are the six decisions that bite in production — we hit each below.

⚠️ Prerequisites. (1) An EKS cluster with an IAM OIDC provider and the AWS Load Balancer Controller running (kubectl get deploy -n kube-system aws-load-balancer-controller returns a ready pod). (2) The hosted zone must be delegated — ACM validation and ExternalDNS both need the domain publicly resolvable. If either is missing, the Ingress will get no ALB / no record and you’ll chase ghosts.

The directory has five files:

File Contains
versions.tf required_version, required_providers (aws/kubernetes/helm), backend "s3", the aws + kubernetes + helm provider blocks
variables.tf Cluster name, OIDC provider ARN + URL, region, domain, app host
main.tf Zone, ACM cert + validation, ExternalDNS IRSA role + policy + helm_release, the sample Deployment/Service/Ingress
outputs.tf App URL, cert ARN, name servers, the ExternalDNS role ARN
terraform.tfvars Your real values

versions.tf — three providers, S3 remote state, and the Kubernetes/Helm providers authenticated to the cluster via EKS data sources:

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws        = { source = "hashicorp/aws",        version = "~> 5.60" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.31" }
    helm       = { source = "hashicorp/helm",       version = "~> 2.14" }
  }

  backend "s3" {
    bucket         = "kv-tfstate-prod-001"
    key            = "eks-ingress-dns/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "kv-tfstate-lock"
    encrypt        = true
  }
}

provider "aws" {
  region = var.region
}

# Authenticate the kubernetes + helm providers to the existing cluster
data "aws_eks_cluster" "this"      { name = var.cluster_name }
data "aws_eks_cluster_auth" "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)
  token                  = data.aws_eks_cluster_auth.this.token
}

provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.this.endpoint
    cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
    token                  = data.aws_eks_cluster_auth.this.token
  }
}

The provider-auth table — the kubernetes/helm providers need cluster credentials, and there are two idioms:

Auth method How Pros Cons
aws_eks_cluster_auth token (shown) A data source mints a short-lived token Simple; no external binary Token can expire in very long applies; re-plan refreshes it
exec plugin An exec block that runs aws eks get-token Always-fresh token; the production default Needs the aws CLI on the runner

For CI or long applies, prefer the exec block (a production note below shows it). The token data source is fine for interactive runs.

variables.tf:

variable "region" {
  type    = string
  default = "ap-south-1"        # Mumbai — the ALB and the ACM cert live here
}

variable "cluster_name" {
  type        = string
  description = "Existing EKS cluster with OIDC + AWS Load Balancer Controller."
  default     = "kv-eks"
}

variable "oidc_provider_arn" {
  type        = string
  description = "ARN of the cluster's IAM OIDC provider (output of the IRSA lesson)."
}

variable "oidc_provider_url" {
  type        = string
  description = "OIDC issuer host WITHOUT https:// (e.g. oidc.eks.ap-south-1.amazonaws.com/id/ABCD1234)."
}

variable "domain_name" {
  type        = string
  description = "A domain delegated to this Route 53 zone."
  default     = "kv-demo.example"
}

variable "app_host" {
  type    = string
  default = "app.kv-demo.example"
}

main.tf — the whole build (zone + cert shown above; here is the rest wired together):

locals {
  tags = { project = "eks-ingress-dns", managedBy = "terraform", env = "demo" }
}

# ---------- Route 53 hosted zone ----------
resource "aws_route53_zone" "primary" {
  name = var.domain_name
  tags = local.tags
}

# ---------- ACM cert (regional — same Region as the ALB) ----------
resource "aws_acm_certificate" "app" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"
  lifecycle { create_before_destroy = true }
  tags = local.tags
}

resource "aws_route53_record" "cert_validation" {
  for_each = { for dvo in aws_acm_certificate.app.domain_validation_options :
    dvo.domain_name => { name = dvo.resource_record_name, record = dvo.resource_record_value, type = dvo.resource_record_type } }
  allow_overwrite = true
  zone_id         = aws_route53_zone.primary.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
}

resource "aws_acm_certificate_validation" "app" {
  certificate_arn         = aws_acm_certificate.app.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

# ---------- ExternalDNS: IRSA role + least-priv Route 53 policy ----------
data "aws_iam_policy_document" "externaldns_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [var.oidc_provider_arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${var.oidc_provider_url}:sub"
      values   = ["system:serviceaccount:external-dns:external-dns"]
    }
    condition {
      test     = "StringEquals"
      variable = "${var.oidc_provider_url}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "externaldns" {
  name               = "kv-externaldns-irsa"
  assume_role_policy = data.aws_iam_policy_document.externaldns_trust.json
  tags               = local.tags
}

data "aws_iam_policy_document" "externaldns" {
  statement {
    sid       = "ChangeThisZoneOnly"
    effect    = "Allow"
    actions   = ["route53:ChangeResourceRecordSets"]
    resources = ["arn:aws:route53:::hostedzone/${aws_route53_zone.primary.zone_id}"]
  }
  statement {
    sid       = "ListAcrossZones"
    effect    = "Allow"
    actions   = ["route53:ListHostedZones", "route53:ListResourceRecordSets", "route53:ListTagsForResources"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "externaldns" {
  name   = "route53-externaldns"
  role   = aws_iam_role.externaldns.id
  policy = data.aws_iam_policy_document.externaldns.json
}

# ---------- ExternalDNS install (Helm) ----------
resource "helm_release" "externaldns" {
  name             = "external-dns"
  namespace        = "external-dns"
  create_namespace = true
  repository       = "https://kubernetes-sigs.github.io/external-dns/"
  chart            = "external-dns"
  version          = "1.15.0"

  values = [yamlencode({
    provider      = "aws"
    sources       = ["ingress", "service"]
    policy        = "upsert-only"
    registry      = "txt"
    txtOwnerId    = var.cluster_name
    domainFilters = [var.domain_name]
    aws           = { region = var.region, zoneType = "public" }
    interval      = "1m"
    serviceAccount = {
      create      = true
      name        = "external-dns"
      annotations = { "eks.amazonaws.com/role-arn" = aws_iam_role.externaldns.arn }
    }
  })]

  depends_on = [aws_iam_role_policy.externaldns]
}

# ---------- Sample app: Deployment + Service + Ingress ----------
resource "kubernetes_namespace_v1" "demo" {
  metadata { name = "demo" }
}

resource "kubernetes_deployment_v1" "web" {
  metadata {
    name      = "web"
    namespace = kubernetes_namespace_v1.demo.metadata[0].name
    labels    = { app = "web" }
  }
  spec {
    replicas = 2
    selector { match_labels = { app = "web" } }
    template {
      metadata { labels = { app = "web" } }
      spec {
        container {
          name  = "web"
          image = "public.ecr.aws/nginx/nginx:1.27"
          port { container_port = 80 }
          readiness_probe {
            http_get {
              path = "/"
              port = 80
            }
            initial_delay_seconds = 5
            period_seconds        = 10
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "web" {
  metadata {
    name      = "web"
    namespace = kubernetes_namespace_v1.demo.metadata[0].name
  }
  spec {
    selector = { app = "web" }
    port {
      port        = 80
      target_port = 80
    }
    type = "ClusterIP"        # target-type: ip registers pods directly; ClusterIP is enough
  }
}

resource "kubernetes_ingress_v1" "web" {
  metadata {
    name      = "web"
    namespace = kubernetes_namespace_v1.demo.metadata[0].name
    annotations = {
      "alb.ingress.kubernetes.io/scheme"           = "internet-facing"
      "alb.ingress.kubernetes.io/target-type"      = "ip"
      "alb.ingress.kubernetes.io/listen-ports"     = "[{\"HTTP\":80},{\"HTTPS\":443}]"
      "alb.ingress.kubernetes.io/ssl-redirect"     = "443"
      "alb.ingress.kubernetes.io/ssl-policy"       = "ELBSecurityPolicy-TLS13-1-2-2021-06"
      "alb.ingress.kubernetes.io/certificate-arn"  = aws_acm_certificate_validation.app.certificate_arn
      "alb.ingress.kubernetes.io/group.name"       = "kv-shared"
      "alb.ingress.kubernetes.io/healthcheck-path" = "/"
    }
  }
  spec {
    ingress_class_name = "alb"
    rule {
      host = var.app_host
      http {
        path {
          path      = "/"
          path_type = "Prefix"
          backend {
            service {
              name = kubernetes_service_v1.web.metadata[0].name
              port { number = 80 }
            }
          }
        }
      }
    }
  }
  # Only write the cert-arn annotation once the cert is ISSUED
  depends_on = [aws_acm_certificate_validation.app, helm_release.externaldns]
}

outputs.tf:

output "app_url"             { value = "https://${var.app_host}" }
output "certificate_arn"     { value = aws_acm_certificate_validation.app.certificate_arn }
output "name_servers"        { value = aws_route53_zone.primary.name_servers }
output "externaldns_role_arn"{ value = aws_iam_role.externaldns.arn }

Run it, step by step

Step 1 — terraform init. Pulls the aws, kubernetes, and helm providers and wires the S3 backend:

Initializing the backend...
Initializing provider plugins...
- Installing hashicorp/aws v5.60.x...
- Installing hashicorp/kubernetes v2.31.x...
- Installing hashicorp/helm v2.14.x...
Terraform has been successfully initialized!

Step 2 — terraform plan -out tfplan. If your zone isn’t delegated yet, first apply just the zone (terraform apply -target=aws_route53_zone.primary), delegate the four name_servers at your registrar, wait for propagation, then plan the rest. Expect a create-only plan:

Plan: 12 to add, 0 to change, 0 to destroy.

The 12: the zone, the ACM cert, two cert_validation records (apex + wildcard, deduped by allow_overwrite), the _validation gate, the IRSA role, its inline policy, the helm_release, the namespace, the Deployment, the Service, and the Ingress.

Step 3 — terraform apply tfplan. ⚠️ This creates real, billed resources — the ALB (once the controller reacts) is the meter. Watch the ordering: the cert is requested, the validation records write, Terraform waits on aws_acm_certificate_validation until ACM issues; the IRSA role and ExternalDNS install; then the Kubernetes objects apply. The Ingress applies in seconds — but the ALB and the DNS record appear a beat later, made by the controllers, not by Terraform:

helm_release.externaldns: Creation complete after 41s
kubernetes_ingress_v1.web: Creation complete after 6s
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:
app_url         = "https://app.kv-demo.example"
certificate_arn = "arn:aws:acm:ap-south-1:111122223333:certificate/abcd-…"
name_servers    = tolist(["ns-123.awsdns-45.com", …])

Step 4 — verify each layer independently. “Apply succeeded” only means the Ingress exists; prove the controllers did their part:

Check Command Expected
Ingress got an ALB address kubectl get ingress -n demo web ADDRESS = k8s-kvshared-…-…​.ap-south-1.elb.amazonaws.com (not empty)
The ALB exists aws elbv2 describe-load-balancers --query "LoadBalancers[].DNSName" --output text The k8s-kvshared-… ALB
ExternalDNS created the record kubectl -n external-dns logs deploy/external-dns | grep app.kv-demo ... CREATE ... app.kv-demo.example A
Hostname resolves to the ALB dig +short app.kv-demo.example The ALB’s public IPs (an A/alias answer)
Ownership TXT exists dig +short TXT a-app.kv-demo.example "heritage=external-dns,external-dns/owner=kv-eks,…"
HTTPS works, valid cert curl -sI https://app.kv-demo.example HTTP/2 200, no cert warning
HTTP redirects to HTTPS curl -sI http://app.kv-demo.example 301, Location: https://app.kv-demo.example/
Cert is ISSUED aws acm describe-certificate --certificate-arn <arn> --query 'Certificate.Status' --output text ISSUED

If ADDRESS stays empty, the controller isn’t reconciling (class/annotations/IRSA — see troubleshooting). If the ALB is up but dig returns nothing, ExternalDNS isn’t writing (IRSA/domainFilters/txtOwnerId). If curl gets a cert warning, the wrong cert attached.

Step 5 — terraform destroy. ⚠️ Order matters here more than anywhere. See the cleanup section — the short version is the Ingress must be destroyed while the AWS Load Balancer Controller is still running, or the ALB it made is orphaned and keeps billing. Because the controller is a prerequisite (managed in its own state), destroying this directory removes the Ingress first (Terraform reverses creation order), the controller sees the deletion and tears down the ALB, and ExternalDNS (under upsert-only) leaves the record — which you delete by hand or by flipping to sync before destroy:

Plan: 0 to add, 0 to change, 12 to destroy.
...
Destroy complete! Resources: 12 destroyed.

Variables, outputs & making it reusable

The demo is already parameterised; to make it a module you lift the ExternalDNS platform (IRSA role + policy + helm_release) into modules/externaldns/ taking oidc_provider_arn, oidc_provider_url, zone_id, and domain_name, and drive apps with a for_each over a map so “one app” becomes “N apps” without new blocks:

variable "apps" {
  type = map(object({
    host      = string
    image     = string
    replicas  = optional(number, 2)
    path      = optional(string, "/")
    group     = optional(string, "kv-shared")
  }))
  default = {}
}

resource "kubernetes_ingress_v1" "app" {
  for_each = var.apps
  metadata {
    name      = each.key
    namespace = kubernetes_namespace_v1.demo.metadata[0].name
    annotations = {
      "alb.ingress.kubernetes.io/scheme"          = "internet-facing"
      "alb.ingress.kubernetes.io/target-type"     = "ip"
      "alb.ingress.kubernetes.io/listen-ports"    = "[{\"HTTP\":80},{\"HTTPS\":443}]"
      "alb.ingress.kubernetes.io/ssl-redirect"    = "443"
      "alb.ingress.kubernetes.io/certificate-arn" = aws_acm_certificate_validation.app.certificate_arn
      "alb.ingress.kubernetes.io/group.name"      = each.value.group   # same group ⇒ shared ALB
    }
  }
  spec {
    ingress_class_name = "alb"
    rule {
      host = each.value.host
      http {
        path {
          path      = each.value.path
          path_type = "Prefix"
          backend {
            service {
              name = each.key
              port { number = 80 }
            }
          }
        }
      }
    }
  }
}

A sensible module input surface:

Input Type Why expose it
cluster_name string Which cluster the providers target
oidc_provider_arn / oidc_provider_url string IRSA trust for ExternalDNS
zone_id / domain_name string The zone ExternalDNS and the cert target
apps map(object) Add apps without touching the module
externaldns_policy string upsert-only vs sync per environment
group_name string Default IngressGroup (the shared-ALB knob)
tags map(string) Cost/ownership

You don’t have to hand-roll every piece — the ecosystem is mature:

Need Registry / chart Roll-your-own when
ExternalDNS install external-dns chart (kubernetes-sigs) Never really — pin the chart, set values
IRSA role terraform-aws-modules/iam//modules/iam-role-for-service-accounts-eks You want the trust/policy visible in review
ACM cert + validation terraform-aws-modules/acm/aws You want the for_each legible, or bespoke SANs
Whole EKS + addons terraform-aws-modules/eks/aws (+ addons) You need full control of the cluster
The controller itself aws-load-balancer-controller chart (installed in the prereq lesson)

The iam-role-for-service-accounts-eks submodule is worth knowing: it has a built-in attach_external_dns_policy = true flag that generates exactly the least-privilege Route 53 policy above and the OIDC trust, so a production module often reduces to a few lines. Keep the raw role inline (as this lesson does) when you want the exact policy statements in the diff. When a local module should graduate to a shared, versioned one, the Terraform on AWS: 3-tier architecture, modules & remote state lesson walks the ladder.

Common mistakes and troubleshooting

Every row here is something that has cost a real engineer real time on exactly this stack:

# Symptom Cause Fix
1 kubectl get ingress shows empty ADDRESS, no ALB appears Controller not reconciling: wrong ingressClassName, controller not running, or its IRSA lacks EC2/ELB perms Set ingressClassName: alb; check kubectl -n kube-system logs deploy/aws-load-balancer-controller; verify the controller’s IRSA
2 ALB is up but the host doesn’t resolve (dig returns nothing) ExternalDNS not writing: bad IRSA, domainFilters mismatch, or wrong txtOwnerId Check kubectl -n external-dns logs deploy/external-dns for AccessDenied; confirm domainFilters includes the host’s domain
3 ExternalDNS logs AccessDenied on ChangeResourceRecordSets IRSA role not assumed, or policy scoped to the wrong zone id Confirm the SA annotation eks.amazonaws.com/role-arn; match the trust sub to system:serviceaccount:external-dns:external-dns; scope the policy to the real zone id
4 HTTPS listener has no cert / browser warning certificate-arn missing or points at an unissued cert; or auto-discovery found nothing in-Region Set certificate-arn to aws_acm_certificate_validation.app.certificate_arn; ensure the cert is in the ALB’s Region
5 404 / 503 from the ALB target-type: instance with a ClusterIP Service, or the health-check path 404s Use target-type: ip (or make the Service NodePort); set healthcheck-path to a real 200 URL
6 Redirect loop on HTTPS ssl-redirect firing on the 443 listener too, or the app also redirects to https ssl-redirect: '443' should only redirect the :80 listener; ensure listen-ports has both 80 and 443; stop the app double-redirecting
7 Cert stuck in PENDING_VALIDATION; apply hangs on _validation Zone not delegated ⇒ validation CNAME not publicly resolvable Delegate the zone’s name_servers at the registrar; dig the CNAME before re-running
8 Two Ingresses each get their own ALB (double cost) Different or missing group.name Give related Ingresses the same group.name to share one ALB
9 Records deleted unexpectedly after a kubectl delete/destroy policy: sync deletes on source removal Use policy: upsert-only; or accept sync with deletion discipline
10 Two clusters fight over the same record (flapping) Same or blank txtOwnerId on both Give each cluster a unique txtOwnerId so the TXT registry arbitrates ownership
11 helm_release / kubernetes_* errors: connection refused / unauthorized Providers can’t reach the cluster; token expired or endpoint wrong Fix the data.aws_eks_cluster* wiring; use the exec auth plugin for long applies
12 terraform destroy leaves an orphaned ALB still billing Ingress deleted after the controller was already gone Destroy the Ingress while the controller runs (keep the controller in its own state); or delete the Ingress first with kubectl
13 New ALB / perpetual diff on every apply listen-ports JSON reformatted, or annotation ordering churn Keep the annotation strings byte-stable; treat the Ingress as the source of truth

Four deserve a sentence of context. Row 1 (empty ADDRESS) is the number-one first-run failure and it’s almost always the class or the controller: the controller literally ignores any Ingress whose ingressClassName isn’t alb, and if the controller pod isn’t running (or its own IRSA can’t call elasticloadbalancing:*), nothing happens and there’s no error on the Ingress — you have to read the controller’s logs. Row 3 (AccessDenied) is the IRSA seam: the ServiceAccount name/namespace in the Helm values must match the sub in the trust policy exactly (system:serviceaccount:external-dns:external-dns), or STS refuses the AssumeRoleWithWebIdentity and ExternalDNS falls back to the node role, which is denied. Row 5 (404/503) is the target-type trap: in ip mode a ClusterIP Service is correct; in instance mode you need a NodePort Service or the ALB has no reachable targets. Row 12 (orphaned ALB) is the destroy-ordering gotcha unique to controller-provisioned resources — the ALB isn’t in Terraform state, so only the controller can delete it, and only while it’s alive.

Cost, cleanup & production notes

Left running, the meter is the ALB; ACM, the zone, and ExternalDNS are rupees (ExternalDNS is just a pod on nodes you already pay for):

Resource Config Rough monthly cost Notes
ACM certificate Public, DNS-validated ₹0 Free for AWS resources; auto-renews free
Route 53 hosted zone 1 public zone ~₹42 ($0.50) + query fees Alias→ALB queries are free
Route 53 queries Standard ~₹34 ($0.40)/million Alias-to-AWS queries don’t count
Application Load Balancer 1 shared ALB (IngressGroup) ~₹1,500–1,900 ($18–22) The dominant cost — LCU + hourly; the meter
ExternalDNS 1 small pod ~₹0 marginal Runs on existing nodes
EKS control plane (prerequisite) ~₹6,100 ($73) Not created here; the cluster is a prereq
This directory’s total ~₹1,600–2,000/mo Almost all of it is the one shared ALB

⚠️ The shared ALB is the whole point of the IngressGroup pattern: ten demo apps on one group.name cost one ALB, not ten. If you’re only learning the flow, terraform destroy between sessions — but mind the ordering below, or the ALB outlives the destroy.

Cleanup ordering — the one rule that matters. The ALB and the DNS record are made by controllers, not Terraform, so:

Order Action Why
1 Delete the Ingress (Terraform does this first on destroy) The AWS LB Controller sees the deletion and tears down the ALB / listener rules
2 ExternalDNS removes the record — only if policy: sync Under upsert-only the record stays; delete it by hand or pre-flip to sync
3 Then destroy ExternalDNS, IRSA, cert, zone Safe once the ALB is gone
⚠️ Never delete the controller/cluster before the Ingress The ALB becomes orphaned — not in TF state, still billing

Because the controller lives in its own state (the prerequisite lesson), a terraform destroy here is safe: the Ingress goes first, the still-running controller reaps the ALB, and only then does ExternalDNS/IRSA/cert/zone tear down. If you ever destroy the cluster stack, delete all alb-class Ingresses first.

Five production hardening notes for this exact layer:

Hardening What to change Why
Scope the IRSA policy to the zone ChangeResourceRecordSets on hostedzone/<id>, not * Least privilege; a compromised pod can’t touch other zones
exec provider auth An exec block running aws eks get-token in the provider Fresh tokens for long applies/CI; the token data source can expire
Pin the chart + ssl-policy Fix helm_release.version; set ELBSecurityPolicy-TLS13-1-2-2021-06 Reproducible installs; modern TLS floor
Keep validation records forever Never delete cert_validation; manage in TF ACM needs them to auto-renew; deletion = silent expiry
Deliberate policy upsert-only in prod unless you want DNS to mirror the cluster Avoid a stray kubectl delete dropping a prod hostname

Two of those tie into the wider build: the IRSA foundation (OIDC provider, trust policies, the reusable role module) is the IRSA lesson, and the controller that turns the Ingress into an ALB — its own IRSA, its Helm install, its subnet tags — is the AWS Load Balancer Controller lesson. The ALB/target-group internals (listeners, rules, health checks, deregistration) are in Terraform on AWS: ELB — ALB, NLB & target groups.

Cheat-sheet

The Ingress annotations you set 90% of the time:

Annotation Value Purpose
kubernetes.io/ingress.classingressClassName alb Bind to the AWS LB Controller
scheme internet-facing / internal Public vs private ALB
target-type ip / instance Pod IPs vs NodePorts
listen-ports [{"HTTP":80},{"HTTPS":443}] Which listeners open
ssl-redirect 443 301 HTTP → HTTPS
certificate-arn validated ACM ARN TLS cert on :443
ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 TLS floor
group.name kv-shared Share one ALB (cost)
healthcheck-path /healthz Target-group health

Resources and their load-bearing arguments:

Resource Must-set Watch out
aws_acm_certificate domain_name, validation_method = "DNS" Regional cert (ALB), not us-east-1
aws_acm_certificate_validation certificate_arn, validation_record_fqdns Wire the listener to this ARN
aws_iam_role (IRSA) assume_role_policy (OIDC trust) sub must equal the SA; aud = sts.amazonaws.com
aws_iam_role_policy (ExternalDNS) route53:ChangeResourceRecordSets scoped to the zone List actions need *
helm_release (external-dns) repository, chart, version, values SA name = IRSA sub; pin the chart
kubernetes_ingress_v1 ingress_class_name = "alb", annotations depends_on the cert validation
kubernetes_service_v1 ClusterIP (for ip target-type) NodePort if target-type: instance

The least-privilege ExternalDNS policy (memorise the shape):

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": ["route53:ChangeResourceRecordSets"],
      "Resource": ["arn:aws:route53:::hostedzone/ZXXXXXXXXXXXX"] },
    { "Effect": "Allow",
      "Action": ["route53:ListHostedZones","route53:ListResourceRecordSets"],
      "Resource": ["*"] }
  ]
}

Command quick-reference:

Task Command
Init / plan / apply terraform init · terraform plan -out tfplan · terraform apply tfplan
Two-phase delegation terraform apply -target=aws_route53_zone.primary → delegate NS → terraform apply
Ingress + ALB address kubectl get ingress -n demo web
ExternalDNS logs kubectl -n external-dns logs deploy/external-dns
Controller logs kubectl -n kube-system logs deploy/aws-load-balancer-controller
Resolve the host dig +short app.kv-demo.example
Ownership TXT dig +short TXT a-app.kv-demo.example
Test HTTPS / redirect curl -sI https://app.kv-demo.example · curl -sI http://app.kv-demo.example
Cert status aws acm describe-certificate --certificate-arn <arn> --query 'Certificate.Status' --output text
Destroy terraform destroy (Ingress first — keep the controller alive)

Interview and exam questions

1. Which resources in this build are not Terraform resources, and why does that matter? The ALB and the Route 53 record — they’re created by controllers (the AWS Load Balancer Controller and ExternalDNS) in reaction to the Ingress, not declared in HCL. It matters for lifecycle: Terraform’s state doesn’t know about them, so destroy can’t delete them directly — only the controllers can, and only while they’re running. That’s the source of the orphaned-ALB gotcha.

2. What makes the AWS Load Balancer Controller provision an ALB for an Ingress? ingressClassName: alb bound to an IngressClass whose controller is ingress.k8s.aws/alb. The controller watches those Ingresses and reads alb.ingress.kubernetes.io/* annotations to build the ALB, listeners, and target groups. Without the class match, the controller ignores the Ingress entirely (empty ADDRESS, no error on the object).

3. Explain target-type: ip vs instance. ip registers pod IPs directly in the target group (via the VPC CNI), so an ordinary ClusterIP Service works and traffic skips the node hop. instance registers nodes on a NodePort, so the Service must be type: NodePort. ip is the modern default; the classic “404/503” bug is instance mode with a ClusterIP Service (no reachable targets).

4. What is an IngressGroup and why use it? alb.ingress.kubernetes.io/group.name merges every Ingress sharing that name onto one ALB, each contributing listener rules (ordered by group.order). It turns N-apps-N-ALBs into N-apps-one-ALB — the primary ALB cost lever. The caveat: membership is a shared string that can span namespaces, so treat the group name as semi-privileged and don’t mix trust boundaries.

5. Where must the ACM certificate live for an ALB, and how does it reach the listener? In the ALB’s own Region (regional service) — not us-east-1, which is the CloudFront rule. It reaches the listener either via the explicit certificate-arn annotation (deterministic, preferred for IaC) or by host auto-discovery (the controller finds an in-Region ACM cert whose domain matches the Ingress host). Wire the annotation to the validated ARN so it only ships once the cert is ISSUED.

6. What exactly does ExternalDNS do, and what triggers a record? It runs as a pod, watches Ingress/Service objects, reads their hostnames plus the ALB address the controller wrote into status.loadBalancer, and creates/updates the matching Route 53 record — an alias A record to the ALB. A change to the Ingress host (or a new Ingress) triggers a reconcile within its interval.

7. Write the least-privilege IAM for ExternalDNS and explain the two statements. route53:ChangeResourceRecordSets scoped to arn:aws:route53:::hostedzone/<ZONE_ID> (the write path — restricted to your zone), plus route53:ListHostedZones/ListResourceRecordSets on * (Route 53 doesn’t allow resource-level scoping on the list actions). The sloppy version uses hostedzone/*; scoping to one zone limits the blast radius.

8. How does IRSA connect the ExternalDNS pod to that role? The pod runs as a ServiceAccount annotated with eks.amazonaws.com/role-arn; the role’s trust policy federates the cluster’s OIDC provider and asserts StringEquals on <oidc>:sub = system:serviceaccount:external-dns:external-dns and <oidc>:aud = sts.amazonaws.com. STS issues temporary creds via AssumeRoleWithWebIdentity. A mismatch between the SA name/namespace and the sub is the classic AccessDenied.

9. sync vs upsert-only — what’s the risk? sync creates, updates, and deletes records when the source object is removed; upsert-only never deletes. The risk with sync is that a routine kubectl delete (or a terraform destroy of the wrong namespace) removes a production DNS record. The TXT registry stops ExternalDNS deleting records it doesn’t own, but it will happily delete the one it made. upsert-only is the safe default.

10. What is the TXT registry and txtOwnerId for? ExternalDNS writes a companion TXT record (heritage=external-dns,external-dns/owner=<txtOwnerId>) next to each managed record and checks it before making changes — so it only touches records it owns. txtOwnerId is that ownership fingerprint; a unique value per cluster lets multiple clusters share one zone without fighting. Blank/shared ids cause record flapping.

11. Your Ingress ADDRESS is empty. Diagnose in order. (a) ingressClassName: alb set? (b) Is the controller running — kubectl -n kube-system get deploy aws-load-balancer-controller? © Its logs for errors (subnet discovery, IRSA perms)? (d) Are the subnets tagged (kubernetes.io/role/elb for public)? The Ingress object shows no error itself; the truth is in the controller logs.

12. (Practical) Why does the Ingress depends_on the ACM validation resource? Because the certificate-arn annotation is wired to aws_acm_certificate_validation.app.certificate_arn, and you want Terraform to write that annotation only after ACM reports ISSUED. Without the dependency (or by referencing the raw aws_acm_certificate.arn), Terraform could apply the Ingress with a not-yet-issued cert, and the HTTPS listener would come up without a valid certificate.

Key takeaways

TerraformawsEKSKubernetesALBIngressACMRoute 53ExternalDNSIRSAhelmTLSIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments