AWS and LocalStack¶
Overview¶
This tutorial demonstrates how to configure ExternalDNS to manage DNS records in LocalStack’s Route53 service using a local Kind (Kubernetes in Docker) cluster.
TL;DR¶
After completing this lab, you will have a Kubernetes environment running as containers in your local development machine with localstack and external-dns.
Prerequisite¶
Before you start, ensure you have:
- A running kubernetes cluster.
- In this tutorial we are going to use kind
kubectlandhelmexternal-dnssource code or helm chartLocalstackhow to documenation- Optional
Architecture Overview¶
In this setup:
Kindprovides a localKubernetesclusterLocalStacksimulates AWS services (specificallyRoute53)ExternalDNSautomatically creates DNS records inLocalStackbased onKubernetesresources
Bootstrap Environment¶
1. Create cluster¶
kind create cluster --config=docs/snippets/tutorials/aws-localstack/kind.yaml
Creating cluster "aws-localstack" ...
✓ Preparing nodes 📦 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Joining worker nodes 🚜
Set kubectl context to "kind-aws-localstack"
You can now use your cluster with:
kubectl cluster-info --context kind-aws-localstack
Verify the cluster is running:
2. Deploy Localstack¶
There are multiple options to configure etcd
- With custom manifest.
- Localstack helm
In this tutorial, we’ll use the second option.
helm repo add localstack https://localstack.github.io/helm-charts
helm upgrade localstack localstack-charts/localstack \
-n localstack \
--create-namespace \
--install \
--atomic \
--wait \
-f docs/snippets/tutorials/aws-localstack/values-localstack.yml
❯❯ Release "localstack" does not exist. Installing it now.
Verify LocalStack is running
3: Create a Hosted Zone in LocalStack¶
Test if we could reach Localstack, Route53 service is available and verify Route53 zones created in localstack.
curl http://127.0.0.1:$NODE_PORT/_localstack/health | jq
docs/snippets/tutorials/aws-localstack/fetch-records.sh
Create extra hosted zones in localstack when required
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
export AWS_ENDPOINT_URL=http://127.0.0.1:32379
aws route53 create-hosted-zone \
--name test.com \
--caller-reference $(date +%s)
4. Configure ExternalDNS¶
Deploy with helm and minimal configuration.
Add the external-dns helm repository and check available versions
helm repo add --force-update external-dns https://kubernetes-sigs.github.io/external-dns/
helm search repo external-dns --versions
Install with required configuration
helm upgrade --install external-dns external-dns/external-dns \
-n default \
-f docs/snippets/tutorials/aws-localstack/values-extdns.yml
❯❯ Release "external-dns" does not exist. Installing it now.
Validate pod status and view logs
Or run it on the host from sources
# required to access localstack
export AWS_REGION=eu-west-1
export AWS_ACCESS_KEY_ID=foo
export AWS_SECRET_ACCESS_KEY=bar
export AWS_ENDPOINT_URL=http://127.0.0.1:32379
go run main.go \
--provider=aws \
--source=service \
--source=ingress \
--source=crd \
--txt-owner-id=aws-localstack \
--domain-filter=example.com \
--domain-filter=local.tld \
--log-level=info
5. Test with a Sample Service¶
Create a test service so that ExternalDNS to create records
# ref: docs/snippets/tutorials/aws-localstack/foo-app.yml
---
apiVersion: v1
kind: Service
metadata:
name: foo-app
annotations:
external-dns.alpha.kubernetes.io/hostname: foo-app.example.com
dns.why/type: aws-localstack-tutorial
spec:
type: ClusterIP
clusterIP: None
ports:
- port: 80
targetPort: 80
protocol: TCP
selector:
app: foo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: foo-app
annotations:
dns.why/type: aws-localstack-tutorial
spec:
replicas: 3
selector:
matchLabels:
app: foo
template:
metadata:
labels:
app: foo
spec:
containers:
- name: foo
image: nginx:latest
ports:
- containerPort: 80
resources:
requests:
memory: "5Mi"
cpu: "25m"
limits:
memory: "5Mi"
cpu: "25m"
Deploy the service
Validate route53 records created
docs/snippets/tutorials/aws-localstack/fetch-records.sh "foo-app"
❯❯ [
{
"Name": "a-foo-app.example.com.",
"Type": "TXT",
},
{
"Name": "foo-app.example.com.",
"Type": "A",
"Value": [
"10.244.1.18",
],
"TTL": 300
}
]
6. Using DNSEndpoint CRD (Advanced)¶
The DNSEndpoint Custom Resource Definition (CRD) provides direct control over DNS records, independent of Services or Ingresses. This is useful for:
- Creating DNS records that don’t correspond to Kubernetes services
- Managing complex DNS configurations (multiple targets, weighted routing)
- Integrating with external systems or custom controllers
Verify the CRD is installed
Example 1: Multiple Records¶
Create a simple A record pointing to a specific IP
# ref: docs/snippets/tutorials/aws-localstack/dnsendpoint-multi.yml
---
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: simple-example
namespace: default
annotations:
dns.why/type: aws-localstack-tutorial
spec:
endpoints:
- dnsName: dnsendpoint-a.example.com
recordTTL: 300
recordType: A
targets:
- 192.168.1.100
- dnsName: dnsendpoint-a-lb.example.com
recordTTL: 200
recordType: A
targets:
- 10.0.1.1
- 10.0.1.2
- 10.0.1.3
- dnsName: dnsendpoint-aaaa.example.com
recordTTL: 600
recordType: AAAA
targets:
- 2001:0db8:85a3:0000:0000:8a2e:0370:7334
Apply and verify
kubectl apply -f docs/snippets/tutorials/aws-localstack/dnsendpoint-multi.yml
# Check the DNSEndpoint status
kubectl get dnsendpoint simple-example -o yaml
# validate
docs/snippets/tutorials/aws-localstack/fetch-records.sh "dnsendpoint-a"
docs/snippets/tutorials/aws-localstack/fetch-records.sh "dnsendpoint-aaaa"
❯❯ [
{
"Name": "a-dnsendpoint-a.example.com.",
"Type": "TXT",
"Value": [
"heritage=external-dns,external-dns/owner=aws-localstack"
],
"TTL": 300
},
{
"Name": "dnsendpoint-a.example.com.",
"Type": "A",
"Value": [
"192.168.1.100"
],
"TTL": 300
},
{
"Name": "dnsendpoint-aaaa.example.com.",
"Type": "AAAA",
"Value": [
"2001:0db8:85a3:0000:0000:8a2e:0370:7334"
],
"TTL": 600
},
]
Example 2: CNAME Record¶
Create a CNAME record pointing to another domain:
# ref: docs/snippets/tutorials/aws-localstack/dnsendpoint-cname.yml
---
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: cname-example
namespace: default
annotations:
dns.why/type: aws-localstack-tutorial
spec:
endpoints:
- dnsName: www.example.com
recordTTL: 600
recordType: CNAME
targets:
- example.com
Apply and verify
kubectl apply -f docs/snippets/tutorials/aws-localstack/dnsendpoint-cname.yml
# Check the DNSEndpoint status
kubectl get dnsendpoint cname-example -o yaml
# validate
docs/snippets/tutorials/aws-localstack/fetch-records.sh "www.example"
❯❯ [
{
"Name": "a-www.example.com.",
"Type": "TXT",
"Value": [
"\"heritage=external-dns,external-dns/owner=aws-localstack,external-dns/resource=crd/default/cname-example\""
],
"TTL": 300
},
{
"Name": "www.example.com.",
"Type": "A",
"Value": [
"example.com"
],
"TTL": 600
}
]
Example 4: TXT Records¶
Create TXT records (useful for domain verification, SPF, DKIM, etc.)
# ref: docs/snippets/tutorials/aws-localstack/dnsendpoint-txt.yml
---
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
name: txt-example
namespace: default
spec:
endpoints:
- dnsName: _acme-challenge.example.com
recordTTL: 300
recordType: TXT
targets:
- "validation-token-12345"
- dnsName: example.com
recordTTL: 3600
recordType: TXT
targets:
- "v=spf1 include:_spf.google.com ~all"
Apply and verify
kubectl apply -f docs/snippets/tutorials/aws-localstack/dnsendpoint-txt.yml
# Check the DNSEndpoint status
kubectl get dnsendpoint txt-example -o yaml
# validate
docs/snippets/tutorials/aws-localstack/fetch-records.sh
7. Test with Service LoadBalancer (Advanced)¶
With Kind, LoadBalancer services won’t get external IPs automatically. You can:
- Use MetalLB for LoadBalancer support in Kind
- Install and run Cloud Provider KInd
- Patch services, to manually assign an Ingress IPs. It just makes the Service appear like a real LoadBalancer for tools/tests.
# ref: docs/snippets/tutorials/aws-localstack/service-lb.yml
---
apiVersion: v1
kind: Service
metadata:
name: loadbalancer-service
annotations:
external-dns.alpha.kubernetes.io/hostname: my-loadbalancer.example.com
dns.why/type: aws-localstack-tutorial
namespace: default
spec:
type: LoadBalancer
ports:
- port: 80
name: http
targetPort: 80
selector:
app: test-app
Apply, patch and verify
kubectl apply -f docs/snippets/tutorials/aws-localstack/service-lb.yml
# patch
kubectl patch svc loadbalancer-service --type=merge \
-p '{"status":{"loadBalancer":{"ingress":[{"ip":"172.18.0.2"}]}}}' --subresource=status
❯❯ service/loadbalancer-service
# validate
docs/snippets/tutorials/aws-localstack/fetch-records.sh "my-loadbalancer"
Cleanup¶
Remove all resources:
Diagrams¶
System Architecture¶
Description: This diagram illustrates the complete setup where ExternalDNS runs inside the Kind cluster, watches Kubernetes Service and Ingress resources, and automatically creates corresponding DNS records in LocalStack’s Route53 service.
Both the Kind cluster and LocalStack container run on the same Docker network, enabling communication between them.
graph TB
subgraph "Host Machine"
kubectl[kubectl CLI]
awscli[AWS CLI]
end
subgraph "Docker Network: kind"
subgraph "Kind Cluster"
subgraph "Control Plane"
api[API Server]
end
subgraph "Namespace: external-dns"
ed[ExternalDNS Pod]
end
subgraph "Namespace: default"
nginx[Nginx Pod]
svc[Service<br/>nginx.example.com]
ing[Ingress<br/>nginx-ingress.example.com]
end
end
ls[LocalStack Container<br/>Route53 Mock]
end
kubectl -->|manages| api
awscli -->|configures DNS| ls
ed -->|watches| svc
ed -->|watches| ing
ed -->|creates/updates<br/>DNS records| ls
api -->|provides resources| ed
svc -->|routes to| nginx
ing -->|routes to| svc
style ed fill:#326ce5,color:#fff
style ls fill:#ff9900,color:#fff
style kubectl fill:#326ce5,color:#fff
style awscli fill:#ff9900,color:#fff
DNS Record Creation Flow¶
Description: This sequence diagram demonstrates the automated DNS lifecycle management. When you create a Service with an ExternalDNS annotation, ExternalDNS detects the new resource, extracts the hostname, and creates corresponding DNS records in LocalStack.
It also creates TXT records for ownership tracking. When the Service is deleted, ExternalDNS automatically cleans up the DNS records.
sequenceDiagram
participant User
participant K8s as Kubernetes API
participant ED as ExternalDNS
participant LS as LocalStack Route53
User->>K8s: kubectl apply -f service.yaml
K8s->>K8s: Service created
Note over ED: Watches for Service changes
K8s->>ED: Service event detected
ED->>ED: Parse annotation:<br/>nginx.example.com
ED->>LS: Check existing records
LS-->>ED: No record exists
ED->>LS: Create A record<br/>nginx.example.com → LoadBalancer IP
LS->>LS: Record created
LS-->>ED: Success
ED->>LS: Create TXT record<br/>"heritage=external-dns,..."
LS-->>ED: Success
Note over ED: Continues watching for changes
User->>K8s: kubectl delete service nginx
K8s->>ED: Service deletion event
ED->>LS: Delete A record
ED->>LS: Delete TXT record
LS-->>ED: Records deleted
ExternalDNS Decision Flow¶
Description: This flowchart illustrates ExternalDNS’s decision-making process.
It checks for DNS annotations, validates the domain filter, ensures IP addresses are available, and uses TXT records to track ownership.
This prevents conflicts when multiple DNS controllers or manual DNS entries exist.
The ownership mechanism ensures ExternalDNS only modifies records it created.
flowchart TD
Start([ExternalDNS detects<br/>Kubernetes resource])
Start --> CheckAnnotation{Has external-dns<br/>annotation?}
CheckAnnotation -->|No| Skip[Skip - No DNS needed]
CheckAnnotation -->|Yes| ExtractHost[Extract hostname from<br/>annotation]
ExtractHost --> CheckDomain{Hostname matches<br/>domain-filter?}
CheckDomain -->|No| Skip2[Skip - Outside managed domain]
CheckDomain -->|Yes| GetIP[Get LoadBalancer IP or<br/>Ingress address]
GetIP --> CheckIP{IP/Address<br/>available?}
CheckIP -->|No| Wait[Wait for IP assignment]
CheckIP -->|Yes| QueryRoute53[Query LocalStack Route53<br/>for existing record]
QueryRoute53 --> CheckExists{Record<br/>exists?}
CheckExists -->|No| Create[Create new A record<br/>+ TXT ownership record]
CheckExists -->|Yes| CheckOwner{Check TXT record<br/>owner ID}
CheckOwner -->|Not owned by us| Skip3[Skip - Managed by<br/>another controller]
CheckOwner -->|Owned by us| CheckIP2{IP changed?}
CheckIP2 -->|No| NoAction[No action needed]
CheckIP2 -->|Yes| Update[Update A record<br/>with new IP]
Create --> Success([DNS record created])
Update --> Success
NoAction --> Success
Skip --> End([End])
Skip2 --> End
Skip3 --> End
Wait --> End
Success --> End
style Start fill:#90EE90,color:#000
style Success fill:#90EE90,color:#000
style Create fill:#ADD8E6,color:#000
style Update fill:#ADD8E6,color:#000
style Skip fill:#FFB6C1,color:#000
style Skip2 fill:#FFB6C1,color:#000
style Skip3 fill:#FFB6C1,color:#000