Deploy ExternalDNS on EKS¶
ExternalDNS automatically creates and manages Route53 DNS records from Kubernetes Gateway, HTTPRoute, Service, and Ingress resources. When a Gateway is assigned an ALB address or a Service receives a LoadBalancer hostname, ExternalDNS reads the declared hostname and writes the corresponding A or CNAME record in Route53. No manual Route53 edits are needed after the initial setup.
ExternalDNS supports two IAM binding methods for pod credentials:
- Method A — EKS Pod Identity
- Method B — IRSA (IAM Roles for Service Accounts)
Both methods grant AWS credentials to the external-dns pod at runtime. Use one method only for a given installation.
Official Sources¶
| Resource | URL |
|---|---|
| ExternalDNS GitHub | https://github.com/kubernetes-sigs/external-dns |
| AWS tutorial (official) | https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/aws.md |
| ArtifactHub (chart) | https://artifacthub.io/packages/helm/external-dns/external-dns |
| EKS IRSA guide | https://docs.aws.amazon.com/eks/latest/userguide/associate-service-account-role.html |
Check ArtifactHub for the latest chart version before installing. The --version flag in the commands below pins the chart to a specific release; replace it with the required version when standardizing on a newer chart release.
How ExternalDNS Works¶
Gateway / HTTPRoute / Service
(hostname: app.example.com declared in spec)
│
▼
ExternalDNS pod
watches these resources via the Kubernetes API
│
▼
AWS Route53
CREATE / UPDATE A or CNAME record:
app.example.com → <ALB DNS name>
ExternalDNS is a read-and-sync agent, not a proxy. It does not sit in the traffic path. It reads hostnames from Kubernetes resources and writes DNS records to Route53.
Prerequisites¶
- An EKS cluster is running and
kubectlis configured. - The AWS Load Balancer Controller is deployed with
NLBGatewayAPI=trueandALBGatewayAPI=truefeature gates enabled. - Gateway API CRDs are installed and a
Gatewayresource is provisioned. - A Route53 hosted zone exists for the target domain.
- Helm is installed.
eksctlis installed.
Deployment Overview¶
Perform the installation in this order:
- Set variables.
- Create the IAM policy.
- Create the namespace.
- Choose one IAM binding method: Pod Identity or IRSA.
- Add the Helm repository.
- Install ExternalDNS.
- Apply the Gateway API sources patch.
- Verify pod health and Route53 updates.
IAM Binding Methods¶
| Item | Method A: Pod Identity | Method B: IRSA |
|---|---|---|
| Pre-requisite component | eks-pod-identity-agent add-on | Cluster OIDC provider |
| Binding mechanism | EKS Pod Identity Association | IAM role + ServiceAccount annotation |
| ServiceAccount annotation required | No | Yes (eks.amazonaws.com/role-arn) |
| Helm credential flag required | No | Yes, when Helm creates the ServiceAccount |
| eksctl command | eksctl create podidentityassociation | eksctl create iamserviceaccount |
| IAM trust model | pods.eks.amazonaws.com | Cluster OIDC provider |
Use Pod Identity as the primary method where organizational standards prefer EKS-native pod credential delivery. Use IRSA where IAM roles for service accounts are the standard.
Step 1 — Set Variables¶
export CLUSTER_NAME=<eks-cluster-name>
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
Verify:
Step 2 — Create the IAM Policy¶
Create the policy document file:
cat > policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:ListTagsForResources"
],
"Resource": [
"arn:aws:route53:::hostedzone/*"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones"
],
"Resource": [
"*"
]
}
]
}
EOF
Create the policy in IAM:
aws iam create-policy \
--policy-name AllowExternalDNSUpdates \
--policy-document file://policy.json
Export the ARN:
export POLICY_ARN=$(aws iam list-policies \
--query 'Policies[?PolicyName==`AllowExternalDNSUpdates`].Arn' \
--output text)
echo "POLICY_ARN : $POLICY_ARN"
Step 3 — Create the Namespace¶
Step 4A — Configure Pod Identity (Method A)¶
Install the Pod Identity Agent if it is not already present:
kubectl get daemonset -n kube-system eks-pod-identity-agent
eksctl create addon \
--cluster $CLUSTER_NAME \
--name eks-pod-identity-agent
Create the Pod Identity Association:
eksctl create podidentityassociation \
--cluster $CLUSTER_NAME \
--namespace external-dns \
--service-account-name external-dns \
--role-name external-dns-pod-identity-role \
--permission-policy-arns $POLICY_ARN
Verify the association:
When using Pod Identity, do not add any eks.amazonaws.com/role-arn annotation in Helm values. Credentials are injected by the Pod Identity Agent.
Step 4B — Configure IRSA (Method B)¶
Create the IAM service account:
eksctl create iamserviceaccount \
--cluster $CLUSTER_NAME \
--namespace external-dns \
--name external-dns \
--attach-policy-arn $POLICY_ARN \
--role-name external-dns-irsa-role \
--override-existing-serviceaccounts \
--approve
Export the role ARN:
export ROLE_ARN=$(aws iam get-role \
--role-name external-dns-irsa-role \
--query 'Role.Arn' \
--output text)
echo "ROLE_ARN : $ROLE_ARN"
The eksctl create iamserviceaccount command creates or updates the Kubernetes ServiceAccount and adds the eks.amazonaws.com/role-arn annotation by default unless --role-only is used.
Step 5 — Add the Helm Repository¶
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update external-dns
Step 6A — Install ExternalDNS with Pod Identity¶
Use the chart default ServiceAccount behavior for Pod Identity. Do not pass any IAM role annotation in Helm values.
Step 6B — Install ExternalDNS with IRSA¶
Use IRSA when IAM roles for service accounts are the credential mechanism.
Pattern 1 — Helm creates the ServiceAccount (optional)¶
Use this pattern when Helm manages the ServiceAccount lifecycle:
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=$ROLE_ARN
Helm creates the ServiceAccount and writes the IRSA annotation based on the provided role ARN.
Pattern 2 — Reuse eksctl-managed ServiceAccount (recommended)¶
Use this pattern when eksctl create iamserviceaccount already created and annotated the external-dns ServiceAccount:
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
--set serviceAccount.create=false \
--set serviceAccount.name=external-dns
Helm reuses the existing ServiceAccount and does not attempt to create or modify it. Do not pass serviceAccount.annotations in Helm when reusing an existing annotated ServiceAccount.
Step 7 — Apply the Gateway API Sources Patch¶
The chart default sources list contains only service and ingress. Add the Gateway API sources explicitly.
Create the patch file:
mkdir -p helm-values/external-dns
cat <<'EOF' > helm-values/external-dns/sources-patch.yaml
sources:
- service
- ingress
- gateway-httproute
- gateway-tlsroute
- gateway-tcproute
- gateway-udproute
EOF
7A — Apply patch with Pod Identity¶
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
-f helm-values/external-dns/sources-patch.yaml
7B — Apply patch with IRSA (Pattern 1 — Helm creates ServiceAccount)¶
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
-f helm-values/external-dns/sources-patch.yaml \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=$ROLE_ARN
7C — Apply patch with IRSA (Pattern 2 — reuse eksctl-managed ServiceAccount, recommended)¶
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
-f helm-values/external-dns/sources-patch.yaml \
--set serviceAccount.create=false \
--set serviceAccount.name=external-dns
Helm deep-merges the patch file with the chart defaults. Only the sources key is replaced; everything else remains at its chart default.
Step 8 — Verify DNS Record Creation¶
Follow ExternalDNS logs and confirm detection of hostnames and Route53 changes:
Look for lines similar to:
time="..." level=info msg="Desired change: CREATE app.example.com A [Id: /hostedzone/ZXXXXX]"
time="..." level=info msg="2 record(s) in zone example.com. were successfully updated"
Confirm the record in Route53:
aws route53 list-resource-record-sets \
--hosted-zone-id <HOSTED_ZONE_ID> \
--query "ResourceRecordSets[?Name=='app.example.com.']"
Confirm DNS resolution:
Quick Sequence¶
Pod Identity (Method A)¶
export CLUSTER_NAME=silver-stack-eks
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
cat > policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:ListTagsForResources"
],
"Resource": ["arn:aws:route53:::hostedzone/*"]
},
{
"Effect": "Allow",
"Action": ["route53:ListHostedZones"],
"Resource": ["*"]
}
]
}
EOF
aws iam create-policy \
--policy-name AllowExternalDNSUpdates \
--policy-document file://policy.json
export POLICY_ARN=$(aws iam list-policies \
--query 'Policies[?PolicyName==`AllowExternalDNSUpdates`].Arn' \
--output text)
kubectl get daemonset -n kube-system eks-pod-identity-agent
eksctl create addon --cluster $CLUSTER_NAME --name eks-pod-identity-agent
kubectl create namespace external-dns
eksctl create podidentityassociation \
--cluster $CLUSTER_NAME \
--namespace external-dns \
--service-account-name external-dns \
--role-name external-dns-pod-identity-role \
--permission-policy-arns $POLICY_ARN
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update external-dns
mkdir -p helm-values/external-dns
cat <<'EOF' > helm-values/external-dns/sources-patch.yaml
sources:
- service
- ingress
- gateway-httproute
- gateway-tlsroute
- gateway-tcproute
- gateway-udproute
EOF
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
-f helm-values/external-dns/sources-patch.yaml
sleep 15
kubectl logs -n external-dns deploy/external-dns
IRSA (Method B, recommended Pattern 2)¶
export CLUSTER_NAME=silver-stack-eks
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
cat > policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:ListTagsForResources"
],
"Resource": ["arn:aws:route53:::hostedzone/*"]
},
{
"Effect": "Allow",
"Action": ["route53:ListHostedZones"],
"Resource": ["*"]
}
]
}
EOF
aws iam create-policy \
--policy-name AllowExternalDNSUpdates \
--policy-document file://policy.json
export POLICY_ARN=$(aws iam list-policies \
--query 'Policies[?PolicyName==`AllowExternalDNSUpdates`].Arn' \
--output text)
kubectl create namespace external-dns
eksctl create iamserviceaccount \
--cluster $CLUSTER_NAME \
--namespace external-dns \
--name external-dns \
--attach-policy-arn $POLICY_ARN \
--role-name external-dns-irsa-role \
--override-existing-serviceaccounts \
--approve
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update external-dns
mkdir -p helm-values/external-dns
cat <<'EOF' > helm-values/external-dns/sources-patch.yaml
sources:
- service
- ingress
- gateway-httproute
- gateway-tlsroute
- gateway-tcproute
- gateway-udproute
EOF
helm upgrade -i external-dns external-dns/external-dns \
--namespace external-dns \
--version 1.20.0 \
-f helm-values/external-dns/sources-patch.yaml \
--set serviceAccount.create=false \
--set serviceAccount.name=external-dns
sleep 15
kubectl logs -n external-dns deploy/external-dns