Repository: kubernetes-sigs/external-dns

Issue:
Fatal error - Another operation of type DeregisterInstance is in progress
<!-- Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!
-->

**What happened**:
With DNS Controller configured with aws_sd provider, it exits with a fatal error occasionally due to a seeming race condition, eventually entering a crash loop. 

```json
{"level":"fatal","msg":"Failed to do run once: operation error ServiceDiscovery: RegisterInstance, https response error StatusCode: 400, RequestID: d270d6a7-a36e-44a7-b88d-72d197c38578, DuplicateRequest: Another operation of type DeregisterInstance and id tr2szldps72jcdtoj2oyb3ckwplbl55r-6buit117 is in progress","time":"2025-02-25T13:51:21Z"}
```

**What you expected to happen**:
Expect aws_sd to complete registration and de-registration successfully without panicking. 

**How to reproduce it (as minimally and precisely as possible)**:
The error seems to manifest when pods are rescheduled between nodes, usually due to Karpenter rebalancing the cluster. This results in changes of IP addresses, requiring recreation of Route53 records. 
A minimal reproduction has not been attempted, as we have a fix.

**Anything else we need to know?**:
We have been running a patched version of DNS controller for our private namespace for approximately 3 years. It was believed this patch was an optimisation, but it turns out it also fixes this issue with interacting with the AWS API. 

A PR that fixes this issue is forthcoming.

A PR was filed for this fix before.

https://github.com/kubernetes-sigs/external-dns/pull/3123


**Environment**:

- External-DNS version (use `external-dns --version`):
0.15.1 

- DNS provider:
AWS Route53

- Others:
Deployment configuration:
```yaml
    - args:
        - --log-level
        - info
        - --log-format
        - json
        - --provider
        - aws-sd
        - --registry
        - aws-sd
        - --policy
        - sync
        - --interval
        - 10s
        - --source
        - service
        - --aws-api-retries
        - "3"
        - --domain-filter
        - company.local
        - --aws-zone-type
        - private
        - --annotation-filter
        - dns.company.com/type=internal
        - --fqdn-template
        - '{{index .ObjectMeta.Annotations "dns.company.com/label"}}.company.local'

```

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/lint-test-chart.yaml b/.github/workflows/lint-test-chart.yaml
index c5ba811f..14514f69 100644
--- a/.github/workflows/lint-test-chart.yaml
+++ b/.github/workflows/lint-test-chart.yaml
@@ -28,7 +28,7 @@ jobs:
         working-directory: charts/external-dns
         run: |
           set -euo pipefail
-          
+
           helm plugin install https://github.com/losisin/helm-values-schema-json.git
           helm schema
           if [[ -n "$(git status --porcelain --untracked-files=no)" ]]
@@ -58,6 +58,12 @@ jobs:
             exit 1
           fi
 
+      - name: Run Helm Unit Tests
+        run: |
+          set -euo pipefail
+          helm plugin install https://github.com/helm-unittest/helm-unittest.git >/dev/null 2>&1
+          helm unittest -f 'tests/*_test.yaml' --color charts/external-dns
+
       - name: Install Artifact Hub CLI
         uses: action-stars/install-tool-from-github-release@ece2623611b240002e0dd73a0d685505733122f6 # v0.2.4
         with:
diff --git a/Makefile b/Makefile
index f81221dd..b28a79fb 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ golangci-lint-verify: golangci-lint
 .PHONY: go-lint
 go-lint: golangci-lint
 	gofmt -l -s -w .
-	golangci-lint run --timeout=30m ./...
+	golangci-lint run --timeout=30m --fix ./...
 
 #? licensecheck: Run the to check for license headers
 .PHONY: licensecheck
@@ -149,7 +149,6 @@ clean:
 	@rm -rf build
 	@go clean -cache
 
-
 .PHONY: release.staging
 #? release.staging: Builds and push container images to the staging bucket.
 release.staging: test
@@ -167,6 +166,11 @@ ko:
 generate-flags-documentation:
 	go run internal/gen/docs/flags/main.go
 
+.PHONY: generate-metrics-documentation
+#? generate-metrics-documentation: Generate documentation (docs/monitoring/metrics.md)
+generate-metrics-documentation:
+	go run internal/gen/docs/metrics/main.go
+
 #? pre-commit-install: Install pre-commit hooks
 pre-commit-install:
 	@pre-commit install
@@ -184,3 +188,11 @@ pre-commit-validate:
 #? help: Get more info on available commands
 help: Makefile
 	@sed -n 's/^#?//p' $< | column -t -s ':' |  sort | sed -e 's/^/ /'
+
+#? helm-test: Run unit tests
+helm-test:
+	scripts/helm-tools.sh --helm-unittest
+
+#? helm-template: Run helm template
+helm-template:
+	scripts/helm-tools.sh --helm-template
diff --git a/charts/external-dns/.helmignore b/charts/external-dns/.helmignore
index 596d9bae..e951b6fb 100644
--- a/charts/external-dns/.helmignore
+++ b/charts/external-dns/.helmignore
@@ -24,3 +24,4 @@
 ci/
 schema/
 .schema.yaml
+tests/
diff --git a/charts/external-dns/CHANGELOG.md b/charts/external-dns/CHANGELOG.md
index d1e974f1..574992f1 100644
--- a/charts/external-dns/CHANGELOG.md
+++ b/charts/external-dns/CHANGELOG.md
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- Added helm testing framework `helm plugin unittest`. ([#5137](https://github.com/kubernetes-sigs/external-dns/pull/5137)) _@ivankatliarchuk_
 - Added ability to generate schema with `helm plugin schema`. ([#5075](https://github.com/kubernetes-sigs/external-dns/pull/5075)) _@ivankatliarchuk_
 - Added `docs/contributing/dev-guide.md#helm-values` guide. ([#5075](https://github.com/kubernetes-sigs/external-dns/pull/5075)) _@ivankatliarchuk_
 
diff --git a/charts/external-dns/values.schema.json b/charts/external-dns/values.schema.json
index 47db1df6..648c5c88 100644
--- a/charts/external-dns/values.schema.json
+++ b/charts/external-dns/values.schema.json
@@ -497,10 +497,16 @@
           "type": "boolean"
         },
         "mountPath": {
-          "type": "null"
+          "type": [
+            "string",
+            "null"
+          ]
         },
         "subPath": {
-          "type": "null"
+          "type": [
+            "string",
+            "null"
+          ]
         }
       },
       "type": "object"
diff --git a/charts/external-dns/values.yaml b/charts/external-dns/values.yaml
index 0bb99a75..e1a299c4 100644
--- a/charts/external-dns/values.yaml
+++ b/charts/external-dns/values.yaml
@@ -302,8 +302,8 @@ secretConfiguration:
   # -- If `true`, create a `Secret` to store sensitive provider configuration (**DEPRECATED**).
   enabled: false
   # -- Mount path for the `Secret`, this can be templated.
-  mountPath:
+  mountPath:  # @schema type:[string, null]; default: null
   # -- Sub-path for mounting the `Secret`, this can be templated.
-  subPath:
+  subPath:  # @schema type:[string, null]; default: null
   # -- `Secret` data.
   data: {}
diff --git a/controller/controller.go b/controller/controller.go
index 2521b443..d98fb810 100644
--- a/controller/controller.go
+++ b/controller/controller.go
@@ -27,6 +27,7 @@ import (
 	log "github.com/sirupsen/logrus"
 
 	"sigs.k8s.io/external-dns/endpoint"
+	"sigs.k8s.io/external-dns/pkg/metrics"
 	"sigs.k8s.io/external-dns/plan"
 	"sigs.k8s.io/external-dns/provider"
 	"sigs.k8s.io/external-dns/registry"
@@ -34,7 +35,7 @@ import (
 )
 
 var (
-	registryErrorsTotal = prometheus.NewCounter(
+	registryErrorsTotal = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Namespace: "external_dns",
 			Subsystem: "registry",
@@ -42,7 +43,7 @@ var (
 			Help:      "Number of Registry errors.",
 		},
 	)
-	sourceErrorsTotal = prometheus.NewCounter(
+	sourceErrorsTotal = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Namespace: "external_dns",
 			Subsystem: "source",
@@ -50,7 +51,7 @@ var (
 			Help:      "Number of Source errors.",
 		},
 	)
-	sourceEndpointsTotal = prometheus.NewGauge(
+	sourceEndpointsTotal = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "source",
@@ -58,7 +59,7 @@ var (
 			Help:      "Number of Endpoints in all sources",
 		},
 	)
-	registryEndpointsTotal = prometheus.NewGauge(
+	registryEndpointsTotal = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "registry",
@@ -66,7 +67,7 @@ var (
 			Help:      "Number of Endpoints in the registry",
 		},
 	)
-	lastSyncTimestamp = prometheus.NewGauge(
+	lastSyncTimestamp = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "controller",
@@ -74,7 +75,7 @@ var (
 			Help:      "Timestamp of last successful sync with the DNS provider",
 		},
 	)
-	lastReconcileTimestamp = prometheus.NewGauge(
+	lastReconcileTimestamp = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "controller",
@@ -82,7 +83,7 @@ var (
 			Help:      "Timestamp of last attempted sync with the DNS provider",
 		},
 	)
-	controllerNoChangesTotal = prometheus.NewCounter(
+	controllerNoChangesTotal = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Namespace: "external_dns",
 			Subsystem: "controller",
@@ -90,21 +91,21 @@ var (
 			Help:      "Number of reconcile loops ending up with no changes on the DNS provider side.",
 		},
 	)
-	deprecatedRegistryErrors = prometheus.NewCounter(
+	deprecatedRegistryErrors = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Subsystem: "registry",
 			Name:      "errors_total",
 			Help:      "Number of Registry errors.",
 		},
 	)
-	deprecatedSourceErrors = prometheus.NewCounter(
+	deprecatedSourceErrors = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Subsystem: "source",
 			Name:      "errors_total",
 			Help:      "Number of Source errors.",
 		},
 	)
-	registryARecords = prometheus.NewGauge(
+	registryARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "registry",
@@ -112,7 +113,7 @@ var (
 			Help:      "Number of Registry A records.",
 		},
 	)
-	registryAAAARecords = prometheus.NewGauge(
+	registryAAAARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "registry",
@@ -120,7 +121,7 @@ var (
 			Help:      "Number of Registry AAAA records.",
 		},
 	)
-	sourceARecords = prometheus.NewGauge(
+	sourceARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "source",
@@ -128,7 +129,7 @@ var (
 			Help:      "Number of Source A records.",
 		},
 	)
-	sourceAAAARecords = prometheus.NewGauge(
+	sourceAAAARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "source",
@@ -136,7 +137,7 @@ var (
 			Help:      "Number of Source AAAA records.",
 		},
 	)
-	verifiedARecords = prometheus.NewGauge(
+	verifiedARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "controller",
@@ -144,7 +145,7 @@ var (
 			Help:      "Number of DNS A-records that exists both in source and registry.",
 		},
 	)
-	verifiedAAAARecords = prometheus.NewGauge(
+	verifiedAAAARecords = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "controller",
@@ -155,21 +156,21 @@ var (
 )
 
 func init() {
-	prometheus.MustRegister(registryErrorsTotal)
-	prometheus.MustRegister(sourceErrorsTotal)
-	prometheus.MustRegister(sourceEndpointsTotal)
-	prometheus.MustRegister(registryEndpointsTotal)
-	prometheus.MustRegister(lastSyncTimestamp)
-	prometheus.MustRegister(lastReconcileTimestamp)
-	prometheus.MustRegister(deprecatedRegistryErrors)
-	prometheus.MustRegister(deprecatedSourceErrors)
-	prometheus.MustRegister(controllerNoChangesTotal)
-	prometheus.MustRegister(registryARecords)
-	prometheus.MustRegister(registryAAAARecords)
-	prometheus.MustRegister(sourceARecords)
-	prometheus.MustRegister(sourceAAAARecords)
-	prometheus.MustRegister(verifiedARecords)
-	prometheus.MustRegister(verifiedAAAARecords)
+	metrics.RegisterMetric.MustRegister(registryErrorsTotal)
+	metrics.RegisterMetric.MustRegister(sourceErrorsTotal)
+	metrics.RegisterMetric.MustRegister(sourceEndpointsTotal)
+	metrics.RegisterMetric.MustRegister(registryEndpointsTotal)
+	metrics.RegisterMetric.MustRegister(lastSyncTimestamp)
+	metrics.RegisterMetric.MustRegister(lastReconcileTimestamp)
+	metrics.RegisterMetric.MustRegister(deprecatedRegistryErrors)
+	metrics.RegisterMetric.MustRegister(deprecatedSourceErrors)
+	metrics.RegisterMetric.MustRegister(controllerNoChangesTotal)
+	metrics.RegisterMetric.MustRegister(registryARecords)
+	metrics.RegisterMetric.MustRegister(registryAAAARecords)
+	metrics.RegisterMetric.MustRegister(sourceARecords)
+	metrics.RegisterMetric.MustRegister(sourceAAAARecords)
+	metrics.RegisterMetric.MustRegister(verifiedARecords)
+	metrics.RegisterMetric.MustRegister(verifiedAAAARecords)
 }
 
 // Controller is responsible for orchestrating the different components.
@@ -203,7 +204,7 @@ type Controller struct {
 
 // RunOnce runs a single iteration of a reconciliation loop.
 func (c *Controller) RunOnce(ctx context.Context) error {
-	lastReconcileTimestamp.SetToCurrentTime()
+	lastReconcileTimestamp.Gauge.SetToCurrentTime()
 
 	c.runAtMutex.Lock()
 	c.lastRunAt = time.Now()
@@ -211,30 +212,30 @@ func (c *Controller) RunOnce(ctx context.Context) error {
 
 	records, err := c.Registry.Records(ctx)
 	if err != nil {
-		registryErrorsTotal.Inc()
-		deprecatedRegistryErrors.Inc()
+		registryErrorsTotal.Counter.Inc()
+		deprecatedRegistryErrors.Counter.Inc()
 		return err
 	}
 
-	registryEndpointsTotal.Set(float64(len(records)))
+	registryEndpointsTotal.Gauge.Set(float64(len(records)))
 	regARecords, regAAAARecords := countAddressRecords(records)
-	registryARecords.Set(float64(regARecords))
-	registryAAAARecords.Set(float64(regAAAARecords))
+	registryARecords.Gauge.Set(float64(regARecords))
+	registryAAAARecords.Gauge.Set(float64(regAAAARecords))
 	ctx = context.WithValue(ctx, provider.RecordsContextKey, records)
 
 	endpoints, err := c.Source.Endpoints(ctx)
 	if err != nil {
-		sourceErrorsTotal.Inc()
-		deprecatedSourceErrors.Inc()
+		sourceErrorsTotal.Counter.Inc()
+		deprecatedSourceErrors.Counter.Inc()
 		return err
 	}
-	sourceEndpointsTotal.Set(float64(len(endpoints)))
+	sourceEndpointsTotal.Gauge.Set(float64(len(endpoints)))
 	srcARecords, srcAAAARecords := countAddressRecords(endpoints)
-	sourceARecords.Set(float64(srcARecords))
-	sourceAAAARecords.Set(float64(srcAAAARecords))
+	sourceARecords.Gauge.Set(float64(srcARecords))
+	sourceAAAARecords.Gauge.Set(float64(srcAAAARecords))
 	vARecords, vAAAARecords := countMatchingAddressRecords(endpoints, records)
-	verifiedARecords.Set(float64(vARecords))
-	verifiedAAAARecords.Set(float64(vAAAARecords))
+	verifiedARecords.Gauge.Set(float64(vARecords))
+	verifiedAAAARecords.Gauge.Set(float64(vAAAARecords))
 	endpoints, err = c.Registry.AdjustEndpoints(endpoints)
 	if err != nil {
 		return fmt.Errorf("adjusting endpoints: %w", err)
@@ -256,16 +257,16 @@ func (c *Controller) RunOnce(ctx context.Context) error {
 	if plan.Changes.HasChanges() {
 		err = c.Registry.ApplyChanges(ctx, plan.Changes)
 		if err != nil {
-			registryErrorsTotal.Inc()
-			deprecatedRegistryErrors.Inc()
+			registryErrorsTotal.Counter.Inc()
+			deprecatedRegistryErrors.Counter.Inc()
 			return err
 		}
 	} else {
-		controllerNoChangesTotal.Inc()
+		controllerNoChangesTotal.Counter.Inc()
 		log.Info("All records are already up to date")
 	}
 
-	lastSyncTimestamp.SetToCurrentTime()
+	lastSyncTimestamp.Gauge.SetToCurrentTime()
 
 	return nil
 }
diff --git a/docs/contributing/dev-guide.md b/docs/contributing/dev-guide.md
index 0ba8c4cb..6c474f14 100644
--- a/docs/contributing/dev-guide.md
+++ b/docs/contributing/dev-guide.md
@@ -38,10 +38,11 @@ make test
 make cover-html
 ```
 
-If added any flags, re-generate flags documentation
+If added any flags or metrics, re-generate documentation
 
 ```shell
 make generate-flags-documentation
+make generate-metrics-documentation
 ```
 
 We require all changes to be covered by acceptance tests and/or unit tests, depending on the situation.
@@ -244,7 +245,13 @@ This helm chart comes with a JSON schema generated from values with [helm schema
 ❯❯ scripts/helm-tools.sh --docs
 ```
 
-6. Add an entry to the chart [CHANGELOG.md](../../charts/external-dns/CHANGELOG.md) under `## UNRELEASED` section and `open` pull request
+6. Run helm unittets.
+
+```sh
+❯❯ make helm-test
+```
+
+7. Add an entry to the chart [CHANGELOG.md](../../charts/external-dns/CHANGELOG.md) under `## UNRELEASED` section and `open` pull request
 
 ## Deploy with kubernetes manifests
 
diff --git a/docs/faq.md b/docs/faq.md
index c3d956e5..4bdb4bbd 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -182,48 +182,6 @@ or
 
 You need to add either https://www.googleapis.com/auth/ndev.clouddns.readwrite or https://www.googleapis.com/auth/cloud-platform on your instance group's scope.
 
-## What metrics can I get from ExternalDNS and what do they mean?
-
-ExternalDNS exposes 2 types of metrics: Sources and Registry errors.
-
-`Source`s are mostly Kubernetes API objects. Examples of `source` errors may be connection errors to the Kubernetes API server itself or missing RBAC permissions.
-It can also stem from incompatible configuration in the objects itself like invalid characters, processing a broken fqdnTemplate, etc.
-
-`Registry` errors are mostly Provider errors, unless there's some coding flaw in the registry package. Provider errors often arise due to accessing their APIs due to network or missing cloud-provider permissions when reading records.
-When applying a changeset, errors will arise if the changeset applied is incompatible with the current state.
-
-In case of an increased error count, you could correlate them with the `http_request_duration_seconds{handler="instrumented_http"}` metric which should show increased numbers for status codes 4xx (permissions, configuration, invalid changeset) or 5xx (apiserver down).
-
-You can use the host label in the metric to figure out if the request was against the Kubernetes API server (Source errors) or the DNS provider API (Registry/Provider errors).
-
-Here is the full list of available metrics provided by ExternalDNS:
-
-| Name                                                     | Description                                                        | Type    |
-| -------------------------------------------------------- | ------------------------------------------------------------------ | ------- |
-| external_dns_controller_last_sync_timestamp_seconds      | Timestamp of last successful sync with the DNS provider            | Gauge   |
-| external_dns_controller_last_reconcile_timestamp_seconds | Timestamp of last attempted sync with the DNS provider             | Gauge   |
-| external_dns_registry_endpoints_total                    | Number of Endpoints in all sources                                 | Gauge   |
-| external_dns_registry_errors_total                       | Number of Registry errors                                          | Counter |
-| external_dns_source_endpoints_total                      | Number of Endpoints in the registry                                | Gauge   |
-| external_dns_source_errors_total                         | Number of Source errors                                            | Counter |
-| external_dns_controller_verified_aaaa_records            | Number of DNS AAAA-records that exists both in source and registry | Gauge   |
-| external_dns_controller_verified_a_records               | Number of DNS A-records that exists both in source and registry    | Gauge   |
-| external_dns_registry_aaaa_records                       | Number of AAAA records in registry                                 | Gauge   |
-| external_dns_registry_a_records                          | Number of A records in registry                                    | Gauge   |
-| external_dns_source_aaaa_records                         | Number of AAAA records in source                                   | Gauge   |
-| external_dns_source_a_records                            | Number of A records in source                                      | Gauge   |
-
-If you're using the webhook provider, the following additional metrics will be provided:
-
-| Name                                                         | Description                                            | Type    |
-| ------------------------------------------------------------ | ------------------------------------------------------ | ------- |
-| external_dns_webhook_provider_records_errors_total           | Number of errors with the /records method              | Gauge   |
-| external_dns_webhook_provider_records_requests_total         | Number of requests made to the /records method         | Gauge   |
-| external_dns_webhook_provider_applychanges_errors_total      | Number of errors with the /applychanges method         | Gauge   |
-| external_dns_webhook_provider_applychanges_requests_total    | Number of requests made to the /applychanges method    | Gauge   |
-| external_dns_webhook_provider_adjustendpoints_errors_total   | Number of errors with the /adjustendpoints method      | Gauge   |
-| external_dns_webhook_provider_adjustendpoints_requests_total | Number of requests made to the /adjustendpoints method | Gauge   |
-
 ## How can I run ExternalDNS under a specific GCP Service Account, e.g. to access DNS records in other projects?
 
 Have a look at https://github.com/linki/mate/blob/v0.6.2/examples/google/README.md#permissions
diff --git a/docs/monitoring/index.md b/docs/monitoring/index.md
new file mode 100644
index 00000000..f796a939
--- /dev/null
+++ b/docs/monitoring/index.md
@@ -0,0 +1,50 @@
+# Monitoring & Observability
+
+Monitoring is a crucial aspect of maintaining the health and performance of your applications.
+It involves collecting, analyzing, and using information to ensure that your system is running smoothly and efficiently. Effective monitoring helps in identifying issues early, understanding system behavior, and making informed decisions to improve performance and reliability.
+
+For `external-dns`, all metrics available for scraping are exposed on the `/metrics` endpoint. The metrics are in the Prometheus exposition format, which is widely used for monitoring and alerting.
+
+To access the metrics:
+
+```sh
+curl https://localhost:7979/metrics
+```
+
+In the metrics output, you'll see the help text, type information, and current value of the `external_dns_registry_endpoints_total` counter:
+
+```yml
+# HELP external_dns_registry_endpoints_total Number of Endpoints in the registry
+# TYPE external_dns_registry_endpoints_total gauge
+external_dns_registry_endpoints_total 11
+```
+
+You can configure a locally running [Prometheus instance](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) to scrape metrics from the application. Here's an example prometheus.yml configuration:
+
+```yml
+scrape_configs:
+- job_name: external-dns
+  scrape_interval: 10s
+  static_configs:
+  - targets:
+    - localhost:7979
+```
+
+For more detailed information on how to instrument application with Prometheus, you can refer to the [Prometheus Go client library documentation](https://prometheus.io/docs/guides/go-application/).
+
+## What metrics can I get from ExternalDNS and what do they mean?
+
+- The project maintain a [metrics page](./metrics.md) with a list of supported custom metrics.
+- [Go runtime](https://pkg.go.dev/runtime/metrics#hdr-Supported_metrics) metrics also available for scraping.
+
+ExternalDNS exposes 3 types of metrics: Sources, Registry errors and Cache hits.
+
+`Source`s are mostly Kubernetes API objects. Examples of `source` errors may be connection errors to the Kubernetes API server itself or missing RBAC permissions.
+It can also stem from incompatible configuration in the objects itself like invalid characters, processing a broken fqdnTemplate, etc.
+
+`Registry` errors are mostly Provider errors, unless there's some coding flaw in the registry package. Provider errors often arise due to accessing their APIs due to network or missing cloud-provider permissions when reading records.
+When applying a changeset, errors will arise if the changeset applied is incompatible with the current state.
+
+In case of an increased error count, you could correlate them with the `http_request_duration_seconds{handler="instrumented_http"}` metric which should show increased numbers for status codes 4xx (permissions, configuration, invalid changeset) or 5xx (apiserver down).
+
+You can use the host label in the metric to figure out if the request was against the Kubernetes API server (Source errors) or the DNS provider API (Registry/Provider errors).
diff --git a/docs/monitoring/metrics.md b/docs/monitoring/metrics.md
new file mode 100644
index 00000000..ae475291
--- /dev/null
+++ b/docs/monitoring/metrics.md
@@ -0,0 +1,89 @@
+# Available Metrics
+
+<!-- THIS FILE MUST NOT BE EDITED BY HAND -->
+<!-- ON NEW METRIC ADDED PLEASE RUN 'make generate-metrics-documentation' -->
+<!-- markdownlint-disable MD013 -->
+
+All metrics available for scraping are exposed on the `/metrics` endpoint.
+The metrics are in the Prometheus exposition format.
+
+To access the metrics:
+
+```sh
+curl https://localhost:7979/metrics
+```
+
+## Supported Metrics
+
+> Full metric name is constructed as follows:
+> `external_dns_<subsystem>_<name>`
+
+| Name                             | Metric Type | Subsystem   |  Help                                                 |
+|:---------------------------------|:------------|:------------|:------------------------------------------------------|
+| last_reconcile_timestamp_seconds | Gauge | controller | Timestamp of last attempted sync with the DNS provider |
+| last_sync_timestamp_seconds | Gauge | controller | Timestamp of last successful sync with the DNS provider |
+| no_op_runs_total | Counter | controller | Number of reconcile loops ending up with no changes on the DNS provider side. |
+| verified_a_records | Gauge | controller | Number of DNS A-records that exists both in source and registry. |
+| verified_aaaa_records | Gauge | controller | Number of DNS AAAA-records that exists both in source and registry. |
+| cache_apply_changes_calls | Counter | provider | Number of calls to the provider cache ApplyChanges. |
+| cache_records_calls | Counter | provider | Number of calls to the provider cache Records list. |
+| a_records | Gauge | registry | Number of Registry A records. |
+| aaaa_records | Gauge | registry | Number of Registry AAAA records. |
+| endpoints_total | Gauge | registry | Number of Endpoints in the registry |
+| errors_total | Counter | registry | Number of Registry errors. |
+| a_records | Gauge | source | Number of Source A records. |
+| aaaa_records | Gauge | source | Number of Source AAAA records. |
+| endpoints_total | Gauge | source | Number of Endpoints in all sources |
+| errors_total | Counter | source | Number of Source errors. |
+| adjustendpoints_errors_total | Gauge | webhook_provider | Errors with AdjustEndpoints method |
+| adjustendpoints_requests_total | Gauge | webhook_provider | Requests with AdjustEndpoints method |
+| applychanges_errors_total | Gauge | webhook_provider | Errors with ApplyChanges method |
+| applychanges_requests_total | Gauge | webhook_provider | Requests with ApplyChanges method |
+| records_errors_total | Gauge | webhook_provider | Errors with Records method |
+| records_requests_total | Gauge | webhook_provider | Requests with Records method |
+
+## Available Go Runtime Metrics
+
+> The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent.
+
+| Name                  |
+|:----------------------|
+| go_gc_duration_seconds |
+| go_gc_gogc_percent |
+| go_gc_gomemlimit_bytes |
+| go_goroutines |
+| go_info |
+| go_memstats_alloc_bytes |
+| go_memstats_alloc_bytes_total |
+| go_memstats_buck_hash_sys_bytes |
+| go_memstats_frees_total |
+| go_memstats_gc_sys_bytes |
+| go_memstats_heap_alloc_bytes |
+| go_memstats_heap_idle_bytes |
+| go_memstats_heap_inuse_bytes |
+| go_memstats_heap_objects |
+| go_memstats_heap_released_bytes |
+| go_memstats_heap_sys_bytes |
+| go_memstats_last_gc_time_seconds |
+| go_memstats_mallocs_total |
+| go_memstats_mcache_inuse_bytes |
+| go_memstats_mcache_sys_bytes |
+| go_memstats_mspan_inuse_bytes |
+| go_memstats_mspan_sys_bytes |
+| go_memstats_next_gc_bytes |
+| go_memstats_other_sys_bytes |
+| go_memstats_stack_inuse_bytes |
+| go_memstats_stack_sys_bytes |
+| go_memstats_sys_bytes |
+| go_sched_gomaxprocs_threads |
+| go_threads |
+| http_request_duration_seconds |
+| process_cpu_seconds_total |
+| process_max_fds |
+| process_open_fds |
+| process_resident_memory_bytes |
+| process_start_time_seconds |
+| process_virtual_memory_bytes |
+| process_virtual_memory_max_bytes |
+| process_network_receive_bytes_total |
+| process_network_transmit_bytes_total |
diff --git a/docs/sources/gateway.md b/docs/sources/gateway.md
index 61913e84..6150b7a5 100644
--- a/docs/sources/gateway.md
+++ b/docs/sources/gateway.md
@@ -83,19 +83,18 @@ The targets from each parent Gateway matching the \*Route are then combined and
 
 ## Dualstack Routes
 
-Gateway resources may be served from an external-loadbalancer which may support both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces.
-External DNS Controller uses the `external-dns.alpha.kubernetes.io/dualstack` annotation to determine this. If this annotation is
-set to `true` then ExternalDNS will create two records (one A record
-and one AAAA record) for each hostname associated with the Route resource.
+Gateway resources may be served from an external-loadbalancer which may support
+both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces. When using the AWS
+Route53 provider, External DNS Controller will always create both A and AAAA
+alias DNS records by default, regardless of whether the load balancer is dual
+stack or not.
 
-Example:
+## Example
 
 ```yaml
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
 metadata:
-  annotations:
-    external-dns.alpha.kubernetes.io/dualstack: "true"
   name: echo
 spec:
   hostnames:
@@ -112,7 +111,3 @@ spec:
             type: PathPrefix
             value: /echo
 ```
-
-The above HTTPRoute resource is backed by a dualstack Gateway.
-ExternalDNS will create both an A `echoserver.example.org` record and
-an AAAA record of the same name, that each are aliases for the same LB.
diff --git a/docs/tutorials/aws-filters.md b/docs/tutorials/aws-filters.md
new file mode 100644
index 00000000..4b76a3f8
--- /dev/null
+++ b/docs/tutorials/aws-filters.md
@@ -0,0 +1,177 @@
+# AWS Filters
+
+This document provides guidance on filtering AWS zones using various strategies and flags.
+
+## Strategies for Scoping Zones
+
+> Without specifying these flags, management applies to all zones.
+
+In order to manage specific zones,  there is a possibility to combine multiple options
+
+| Argument                   | Description                                                | Flow Control |
+|:---------------------------|:-----------------------------------------------------------|:------------:|
+| `--zone-id-filter`         | Specify multiple times if needed                           |      OR      |
+| `--domain-filter`          | By domain suffix - specify multiple times if needed        |      OR      |
+| `--regex-domain-filter`    | By domain suffix but as a regex - overrides domain-filter  |     AND      |
+| `--exclude-domains`        | To exclude a domain or subdomain                           |      OR      |
+| `--regex-domain-exclusion` | Subtracts its matches from `regex-domain-filter`'s matches |     AND      |
+| `--aws-zone-type`          | Only sync zones of this type `[public\|private]`           |      OR      |
+| `--aws-zone-tags`          | Only sync zones with this tag                              |     AND      |
+
+Minimum required configuration
+
+```sh
+args:
+    --provider=aws
+    --registry=txt
+    --source=service
+```
+
+### Filter by Zone Type
+
+> If this flag is not specified, management applies to both public and private zones.
+
+```sh
+args:
+    --aws-zone-type=private|public # choose between public or private
+    ...
+```
+
+### Filter by Domain
+
+> Specify multiple times if needed.
+
+```sh
+args:
+    --domain-filter=example.com
+    --domain-filter=.paradox.example.com
+    ...
+```
+
+Example `--domain-filter=example.com` will allow for zone `example.com` and any zones that end in `.example.com`, including `an.example.com`, i.e., the subdomains of example.com.
+
+When there are multiple domains, filter `--domain-filter=example.com` will match domains `example.com`, `ex.par.example.com`, `par.example.com`, `x.par.eu-west-1.example.com`.
+
+And if the filter is prepended with `.` e.g., `--domain-filter=.example.com` it will allow *only* zones that end in `.example.com`, i.e., the subdomains of example.com but not the `example.com` zone itself. Example result: `ex.par.eu-west-1.example.com`, `ex.par.example.com`, `par.example.com`.
+
+> Note: if you prepend the filter with ".", it will not attempt to match parent zones.
+
+### Filter by Zone ID
+
+> Specify multiple times if needed, the flow logic is OR
+
+```sh
+args:
+    --zone-id-filter=ABCDEF12345678
+    --zone-id-filter=XYZDEF12345888
+    ...
+```
+
+### Filter by Tag
+
+> Specify multiple times if needed, the flow logic is AND
+
+Keys only
+
+```sh
+args:
+    --aws-zone-tags=owner
+    --aws-zone-tags=vertical
+```
+
+Or specify keys with values
+
+```sh
+args:
+    --aws-zone-tags=owner=k8s
+    --aws-zone-tags=vertical=k8s
+```
+
+Can't specify multiple or separate values with commas: `key1=val1,key2=val2` at the moment.
+Filter only by value `--aws-zone-tags==tag-value` is not supported.
+
+```sh
+args:
+    --aws-zone-tags=team=k8s,vertical=platform # this is not supported
+    --aws-zone-tags==tag-value # this is not supported
+```
+
+## Filtering Workflows
+
+***Filtering Sequence***
+
+The diagram describes the sequence for filtering AWS zones.
+
+```mermaid
+flowchart TD
+    A["zones"] --> B{"Is zonesCache valid?"}
+    B -- Yes --> C["Return cached zones"]
+    B -- No --> D["Initialize zones map"]
+    D --> E["For each profile and client"]
+    E --> F["Create paginator"]
+    F --> G{"Has more pages?"}
+    G -- Yes --> H["Get next page"]
+    H --> I["For each zone in page"]
+    I --> J{"Match zoneIDFilter?"}
+    J -- No --> G
+    J -- Yes --> K{"Match zoneTypeFilter?"}
+    K -- No --> G
+    K -- Yes --> L{"Match domainFilter?"}
+    L -- No --> M{"zoneMatchParent?"}
+    M -- No --> G
+    M -- Yes --> N{"Match domainFilter parent?"}
+    N -- No --> G
+    N -- Yes --> O{"zoneTagFilter specified?"}
+    O -- Yes --> P["Add zone to zonesToValidate"]
+    O -- No --> Q["Add zone to zones map"]
+    P --> Q
+    Q --> G
+    G -- No --> R{"zonesToValidate not empty?"}
+    R -- Yes --> S["Get tags for zones"]
+    S --> T["For each zone and tags"]
+    T --> U{"Match zoneTagFilter?"}
+    U -- No --> V["Delete zone from zones map"]
+    U -- Yes --> W["Keep zone in zones map"]
+    V --> W
+    W --> R
+    R -- No --> X["Update zonesCache"]
+    X --> Y["Return zones"]
+```
+
+***Filtering Flow***
+
+The is a sequence diagram that describes the interaction between `external-dns`, `AWSProvider`, and `Route53Client`
+during the filtering process. Here is a high-level description:
+
+```mermaid
+sequenceDiagram
+    participant external-dns
+    participant AWSProvider
+    participant Route53Client
+
+    external-dns->>AWSProvider: zones
+    alt Cache is valid
+        AWSProvider-->>external-dns: return cached zones
+    else
+
+        AWSProvider->>Route53Client: ListHostedZonesPaginator
+        loop While paginator.HasMorePages
+            Route53Client->>AWSProvider: paginator.NextPage
+            alt ThrottlingException
+                AWSProvider->>external-dns: error
+            else
+                AWSProvider-->>external-dns: return error
+            end
+            AWSProvider->>AWSProvider: Filter zones
+            alt Tags need validation
+                AWSProvider->>Route53Client: ListTagsForResources
+                Route53Client->>AWSProvider: return tags
+                AWSProvider->>AWSProvider: Validate tags
+            end
+        end
+        alt Cache duration > 0
+            AWSProvider->>AWSProvider: Update cache
+        end
+        AWSProvider-->>external-dns: return zones
+    end
+```
diff --git a/docs/tutorials/aws-load-balancer-controller.md b/docs/tutorials/aws-load-balancer-controller.md
index 7cd6285e..e6274400 100644
--- a/docs/tutorials/aws-load-balancer-controller.md
+++ b/docs/tutorials/aws-load-balancer-controller.md
@@ -101,11 +101,17 @@ spec:
 The above should result in the creation of an (ipv4) ALB in AWS which will forward
 traffic to the echoserver application.
 
-If the `source=ingress` argument is specified, then ExternalDNS will create DNS
-records based on the hosts specified in ingress objects. The above example would
-result in two alias records being created, `echoserver.mycluster.example.org` and
-`echoserver.example.org`, which both alias the ALB that is associated with the
-Ingress object.
+If the `--source=ingress` argument is specified, then ExternalDNS will create
+DNS records based on the hosts specified in ingress objects. The above example
+would result in two alias records (A and AAAA) being created for each of the
+domains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All
+four records alias the ALB that is associated with the Ingress object. As the
+ALB is IPv4 only, the AAAA alias records have no effect.
+
+If you would like ExternalDNS to not create AAAA records at all, you can add the
+following command line parameter: `--exclude-record-types=AAAA`. Please be
+aware, this will disable AAAA record creation even for dualstack enabled load
+balancers.
 
 Note that the above example makes use of the YAML anchor feature to avoid having
 to repeat the http section for multiple hosts that use the exact same paths. If
@@ -138,15 +144,17 @@ In the above example we create a default path that works for any hostname, and
 make use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create
 multiple aliases for the resulting ALB.
 
-## Dualstack ALBs
+## Dualstack Load Balancers
 
-AWS [supports][4] both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs.
-The AWS Load Balancer Controller uses the `alb.ingress.kubernetes.io/ip-address-type`
-annotation (which defaults to `ipv4`) to determine this. If this annotation is
-set to `dualstack` then ExternalDNS will create two alias records (one A record
-and one AAAA record) for each hostname associated with the Ingress object.
+AWS [supports both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs][4]
+and [NLBs][5]. The AWS Load Balancer Controller uses the `alb.ingress.kubernetes.io/ip-address-type`
+annotation (which defaults to `ipv4`) to determine this. ExternalDNS creates
+both A and AAAA alias DNS records by default, regardless of this annotation.
+It's possible to create only A records with the following command line
+parameter: `--exclude-record-types=AAAA`
 
 [4]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type
+[5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type
 
 Example:
 
@@ -174,5 +182,4 @@ spec:
 ```
 
 The above Ingress object will result in the creation of an ALB with a dualstack
-interface. ExternalDNS will create both an A `echoserver.example.org` record and
-an AAAA record of the same name, that each are aliases for the same ALB.
+interface.
diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md
index ed697e11..d1ecdf92 100644
--- a/docs/tutorials/aws.md
+++ b/docs/tutorials/aws.md
@@ -536,7 +536,7 @@ Annotations which are specific to AWS.
 
 ### alias
 
-`external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create an ALIAS record when the target is an ALIAS as well.
+`external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create two ALIAS records (one 'A' for IPv4 and one 'AAAA' for IPv6) when the target is an ALIAS as well.
 To make the target an alias, the ingress needs to be configured correctly as described in [the docs](./gke-nginx.md#with-a-separate-tcp-load-balancer).
 In particular, the argument `--publish-service=default/nginx-ingress-controller` has to be set on the `nginx-ingress-controller` container.
 If one uses the `nginx-ingress` Helm chart, this flag can be set with the `controller.publishService.enabled` configuration option.
@@ -650,6 +650,32 @@ This should show something like:
 ]
 ```
 
+Or for IPv6 (AAAA) records:
+
+```bash
+aws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \
+  --query "ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'AAAA']"
+```
+
+This should show something like:
+
+```json
+[
+    {
+        "Name": "nginx.example.com.",
+        "Type": "AAAA",
+        "AliasTarget": {
+            "HostedZoneId": "ZEWFWZ4R16P7IB",
+            "DNSName": "ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.",
+            "EvaluateTargetHealth": true
+        }
+    }
+]
+```
+
+IPv6 (AAAA) records are created when ALIAS is enabled even for load balancers that do not have dualstack enabled.
+However, Route53 returns empty sets when querying such records, meaning they are harmless and IPv4 will work as normal.
+
 You can also fetch the corresponding text records:
 
 ```bash
@@ -674,9 +700,9 @@ This will show something like:
 ]
 ```
 
-Note created TXT record alongside ALIAS record. TXT record signifies that the corresponding ALIAS record is managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means.
+Note created TXT record alongside ALIAS records. TXT record signifies that the corresponding ALIAS records are managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means.
 
-For more information about ALIAS record, see [Choosing between alias and non-alias records](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html).
+For more information about ALIAS records, see [Choosing between alias and non-alias records](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html).
 
 Let's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first.
 
@@ -876,6 +902,11 @@ env:
 
 The DynamoDB Registry can be used to store dns records metadata. See the [DynamoDB Registry Tutorial](../registry/dynamodb.md) for more information.
 
+## Disable AAAA Record Creation
+
+If you would like ExternalDNS to not create AAAA records at all, you can add the following command line parameter: `--exclude-record-types=AAAA`.
+Please be aware, this will disable AAAA record creation even for dualstack enabled load balancers.
+
 ## Clean up
 
 Make sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly.
@@ -1014,97 +1045,3 @@ Because those limits are in place, `aws-batch-change-size` can be set to any val
 ## Using CRD source to manage DNS records in AWS
 
 Please refer to the [CRD source documentation](../sources/crd.md#example) for more information.
-
-## Strategies for Scoping Zones
-
-> Without specifying these flags, management applies to all zones.
-
-In order to manage specific zones,  you may need to combine multiple options
-
-| Argument                    | Description                                                                 | Flow Control |
-|:----------------------------|:----------------------------------------------------------------------------|:------------:|
-| `--zone-id-filter`          | Specify multiple times if needed                                            |      OR      |
-| `--domain-filter`           | By domain suffix - specify multiple times if needed                         |      OR      |
-| `--regex-domain-filter`     | By domain suffix but as a regex - overrides domain-filter                   |     AND      |
-| `--exclude-domains`         | To exclude a domain or subdomain                                            |      OR      |
-| `--regex-domain-exclusion`  | Subtracts its matches from `regex-domain-filter`'s matches                  |     AND      |
-| `--aws-zone-type`           | Only sync zones of this type `[public\|private]`                            |      OR      |
-| `--aws-zone-tags`           | Only sync zones with this tag                                               |     AND      |
-
-Minimum required configuration
-
-```sh
-args:
-    --provider=aws
-    --registry=txt
-    --source=service
-```
-
-### Filter by Zone Type
-
-> If this flag is not specified, management applies to both public and private zones.
-
-```sh
-args:
-    --aws-zone-type=private|public # choose between public or private
-    ...
-```
-
-### Filter by Domain
-
-> Specify multiple times if needed.
-
-```sh
-args:
-    --domain-filter=example.com
-    --domain-filter=.paradox.example.com
-    ...
-```
-
-Example `--domain-filter=example.com` will allow for zone `example.com` and any zones that end in `.example.com`, including `an.example.com`, i.e., the subdomains of example.com.
-
-When there are multiple domains, filter `--domain-filter=example.com` will match domains `example.com`, `ex.par.example.com`, `par.example.com`, `x.par.eu-west-1.example.com`.
-
-And if the filter is prepended with `.` e.g., `--domain-filter=.example.com` it will allow *only* zones that end in `.example.com`, i.e., the subdomains of example.com but not the `example.com` zone itself. Example result: `ex.par.eu-west-1.example.com`, `ex.par.example.com`, `par.example.com`.
-
-> Note: if you prepend the filter with ".", it will not attempt to match parent zones.
-
-### Filter by Zone ID
-
-> Specify multiple times if needed, the flow logic is OR
-
-```sh
-args:
-    --zone-id-filter=ABCDEF12345678
-    --zone-id-filter=XYZDEF12345888
-    ...
-```
-
-### Filter by Tag
-
-> Specify multiple times if needed, the flow logic is AND
-
-Keys only
-
-```sh
-args:
-    --aws-zone-tags=owner
-    --aws-zone-tags=vertical
-```
-
-Or specify keys with values
-
-```sh
-args:
-    --aws-zone-tags=owner=k8s
-    --aws-zone-tags=vertical=k8s
-```
-
-Can't specify multiple or separate values with commas: `key1=val1,key2=val2` at the moment.
-Filter only by value `--aws-zone-tags==tag-value` is not supported.
-
-```sh
-args:
-    --aws-zone-tags=team=k8s,vertical=platform # this is not supported
-    --aws-zone-tags==tag-value # this is not supported
-```
diff --git a/docs/tutorials/kube-ingress-aws.md b/docs/tutorials/kube-ingress-aws.md
index 3c25bfff..f81e2ae6 100644
--- a/docs/tutorials/kube-ingress-aws.md
+++ b/docs/tutorials/kube-ingress-aws.md
@@ -161,11 +161,17 @@ spec:
 The above should result in the creation of an (ipv4) ALB in AWS which will forward
 traffic to skipper which will forward to the echoserver application.
 
-If the `--source=ingress` argument is specified, then ExternalDNS will create DNS
-records based on the hosts specified in ingress objects. The above example would
-result in two alias records being created, `echoserver.mycluster.example.org` and
-`echoserver.example.org`, which both alias the ALB that is associated with the
-Ingress object.
+If the `--source=ingress` argument is specified, then ExternalDNS will create
+DNS records based on the hosts specified in ingress objects. The above example
+would result in two alias records (A and AAAA) being created for each of the
+domains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All
+four records alias the ALB that is associated with the Ingress object. As the
+ALB is IPv4 only, the AAAA alias records have no effect.
+
+If you would like ExternalDNS to not create AAAA records at all, you can add the
+following command line parameter: `--exclude-record-types=AAAA`. Please be
+aware, this will disable AAAA record creation even for dualstack enabled load
+balancers.
 
 Note that the above example makes use of the YAML anchor feature to avoid having
 to repeat the http section for multiple hosts that use the exact same paths. If
@@ -197,13 +203,14 @@ In the above example we create a default path that works for any hostname, and
 make use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create
 multiple aliases for the resulting ALB.
 
-## Dualstack ALBs
+## NLBs
 
-AWS [supports](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type) both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs.
-The Kubernetes Ingress AWS controller supports the `alb.ingress.kubernetes.io/ip-address-type`
-annotation (which defaults to `ipv4`) to determine this. If this annotation is
-set to `dualstack` then ExternalDNS will create two alias records (one A record
-and one AAAA record) for each hostname associated with the Ingress object.
+AWS has
+[NLBs](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html)
+and [kube-ingress-aws-controller][1] is able to create NLBs instead of ALBs.
+The Kubernetes Ingress AWS controller supports the `zalando.org/aws-load-balancer-type`
+annotation (which defaults to `alb`) to determine this. If this annotation is
+set to `nlb` then ExternalDNS will create an NLB instead of an ALB.
 
 Example:
 
@@ -212,7 +219,7 @@ apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   annotations:
-    alb.ingress.kubernetes.io/ip-address-type: dualstack
+    zalando.org/aws-load-balancer-type: nlb
   name: echoserver
 spec:
   ingressClassName: skipper
@@ -229,18 +236,32 @@ spec:
         pathType: Prefix
 ```
 
-The above Ingress object will result in the creation of an ALB with a dualstack
-interface. ExternalDNS will create both an A `echoserver.example.org` record and
-an AAAA record of the same name, that each are aliases for the same ALB.
+The above Ingress object will result in the creation of an NLB. A
+successful create, you can observe in the ingress `status` field, that is
+written by [kube-ingress-aws-controller][1]:
 
-## NLBs
+```yaml
+status:
+  loadBalancer:
+    ingress:
+    - hostname: kube-ing-lb-atedkrlml7iu-1681027139.$region.elb.amazonaws.com
+```
 
-AWS has
-[NLBs](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html)
-and [kube-ingress-aws-controller][1] is able to create NLBs instead of ALBs.
-The Kubernetes Ingress AWS controller supports the `zalando.org/aws-load-balancer-type`
-annotation (which defaults to `alb`) to determine this. If this annotation is
-set to `nlb` then ExternalDNS will create an NLB instead of an ALB.
+ExternalDNS will create A and AAAA alias records for: `echoserver.example.org`.
+ExternalDNS will use these alias records to automatically maintain IP addresses
+of the NLB.
+
+## Dualstack Load Balancers
+
+AWS [supports both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs][5]
+and [NLBs][6]. The Kubernetes Ingress AWS controller supports the `alb.ingress.kubernetes.io/ip-address-type`
+annotation (which defaults to `ipv4`) to determine this. ExternalDNS creates
+both A and AAAA alias DNS records by default, regardless of this annotation.
+It's possible to create only A records with the following command line
+parameter: `--exclude-record-types=AAAA`
+
+[5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type
+[6]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type
 
 Example:
 
@@ -249,7 +270,7 @@ apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
   annotations:
-    zalando.org/aws-load-balancer-type: nlb
+    alb.ingress.kubernetes.io/ip-address-type: dualstack
   name: echoserver
 spec:
   ingressClassName: skipper
@@ -266,19 +287,8 @@ spec:
         pathType: Prefix
 ```
 
-The above Ingress object will result in the creation of an NLB. A
-successful create, you can observe in the ingress `status` field, that is
-written by [kube-ingress-aws-controller][1]:
-
-```yaml
-status:
-  loadBalancer:
-    ingress:
-    - hostname: kube-ing-lb-atedkrlml7iu-1681027139.$region.elb.amazonaws.com
-```
-
-ExternalDNS will create a A-records `echoserver.example.org`, that
-use AWS ALIAS record to automatically maintain IP addresses of the NLB.
+The above Ingress object will result in the creation of an ALB with a dualstack
+interface.
 
 ## RouteGroup (optional)
 
diff --git a/endpoint/labels.go b/endpoint/labels.go
index 0af44eaf..7ee9cf8d 100644
--- a/endpoint/labels.go
+++ b/endpoint/labels.go
@@ -41,9 +41,6 @@ const (
 	// supposed to be inserted by AWS SD Provider, and parsed into OwnerLabelKey and ResourceLabelKey key by AWS SD Registry
 	AWSSDDescriptionLabel = "aws-sd-description"
 
-	// DualstackLabelKey is the name of the label that identifies dualstack endpoints
-	DualstackLabelKey = "dualstack"
-
 	// txtEncryptionNonce label for keep same nonce for same txt records, for prevent different result of encryption for same txt record, it can cause issues for some providers
 	txtEncryptionNonce = "txt-encryption-nonce"
 )
diff --git a/internal/gen/docs/flags/main.go b/internal/gen/docs/flags/main.go
index c86b7860..dc8053d6 100644
--- a/internal/gen/docs/flags/main.go
+++ b/internal/gen/docs/flags/main.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2017 The Kubernetes Authors.
+Copyright 2025 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -18,14 +18,21 @@ package main
 
 import (
 	"bytes"
+	"embed"
 	"fmt"
 	"os"
 	"strings"
 	"text/template"
 
+	"sigs.k8s.io/external-dns/internal/gen/docs/utils"
 	cfg "sigs.k8s.io/external-dns/pkg/apis/externaldns"
 )
 
+var (
+	//go:embed "templates/*"
+	templates embed.FS
+)
+
 type Flag struct {
 	Name        string
 	Description string
@@ -37,19 +44,6 @@ func (f *Flags) addFlag(name, description string) {
 	*f = append(*f, Flag{Name: name, Description: description})
 }
 
-const markdownTemplate = `# Flags
-
-<!-- THIS FILE MUST NOT BE EDITED BY HAND -->
-<!-- ON NEW FLAG ADDED PLEASE RUN 'make generate-flags-documentation' -->
-<!-- markdownlint-disable MD013 -->
-
-| Flag | Description  |
-| :------ | :----------- |
-{{- range . }}
-| {{ .Name }} | {{ .Description }} |
-{{- end -}}
-`
-
 // It generates a markdown file
 // with the supported flags and writes it to the 'docs/flags.md' file.
 // to re-generate `docs/flags.md` execute 'go run internal/gen/main.go'
@@ -64,7 +58,7 @@ func main() {
 		_ = fmt.Errorf("failed to generate markdown file '%s': %v\n", path, err.Error())
 	}
 	content = content + "\n"
-	_ = writeToFile(path, content)
+	_ = utils.WriteToFile(path, content)
 }
 
 func computeFlags() Flags {
@@ -94,25 +88,13 @@ func computeFlags() Flags {
 }
 
 func (f *Flags) generateMarkdownTable() (string, error) {
-	tmpl := template.Must(template.New("flags.md.tpl").Parse(markdownTemplate))
+	tmpl := template.New("").Funcs(utils.FuncMap())
+	template.Must(tmpl.ParseFS(templates, "templates/*.gotpl"))
 
 	var b bytes.Buffer
-	err := tmpl.Execute(&b, f)
+	err := tmpl.ExecuteTemplate(&b, "flags.gotpl", f)
 	if err != nil {
 		return "", err
 	}
 	return b.String(), nil
 }
-
-func writeToFile(filename string, content string) error {
-	file, fileErr := os.Create(filename)
-	if fileErr != nil {
-		_ = fmt.Errorf("failed to create file: %v", fileErr)
-	}
-	defer file.Close()
-	_, writeErr := file.WriteString(content)
-	if writeErr != nil {
-		_ = fmt.Errorf("failed to write to file: %s", filename)
-	}
-	return nil
-}
diff --git a/internal/gen/docs/flags/templates/flags.gotpl b/internal/gen/docs/flags/templates/flags.gotpl
new file mode 100644
index 00000000..bfc32d56
--- /dev/null
+++ b/internal/gen/docs/flags/templates/flags.gotpl
@@ -0,0 +1,11 @@
+# Flags
+
+<!-- THIS FILE MUST NOT BE EDITED BY HAND -->
+<!-- ON NEW FLAG ADDED PLEASE RUN 'make generate-flags-documentation' -->
+<!-- markdownlint-disable MD013 -->
+
+| Flag | Description  |
+| :------ | :----------- |
+{{- range . }}
+| {{ .Name }} | {{ .Description }} |
+{{- end -}}
diff --git a/internal/gen/docs/metrics/main.go b/internal/gen/docs/metrics/main.go
new file mode 100644
index 00000000..f529a60c
--- /dev/null
+++ b/internal/gen/docs/metrics/main.go
@@ -0,0 +1,122 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"os"
+	"reflect"
+	"sort"
+	"strings"
+	"text/template"
+	"unsafe"
+
+	"github.com/prometheus/client_golang/prometheus"
+
+	"sigs.k8s.io/external-dns/internal/gen/docs/utils"
+	"sigs.k8s.io/external-dns/pkg/metrics"
+
+	// the imports is necessary for the code generation process.
+	_ "sigs.k8s.io/external-dns/controller"
+	_ "sigs.k8s.io/external-dns/provider"
+	_ "sigs.k8s.io/external-dns/provider/webhook"
+)
+
+var (
+	//go:embed "templates/*"
+	templates embed.FS
+)
+
+func main() {
+	testPath, _ := os.Getwd()
+	path := fmt.Sprintf("%s/docs/monitoring/metrics.md", testPath)
+	fmt.Printf("generate file '%s' with configured metrics\n", path)
+
+	content, err := generateMarkdownTable(metrics.RegisterMetric, true)
+	if err != nil {
+		_, _ = fmt.Fprintf(os.Stderr, "failed to generate markdown file '%s': %v\n", path, err)
+		os.Exit(1)
+	}
+	content = content + "\n"
+	_ = utils.WriteToFile(path, content)
+}
+
+func generateMarkdownTable(m *metrics.MetricRegistry, withRuntime bool) (string, error) {
+	tmpl := template.New("").Funcs(utils.FuncMap())
+	template.Must(tmpl.ParseFS(templates, "templates/*.gotpl"))
+
+	sortMetrics(m.Metrics)
+	var runtimeMetrics []string
+	if withRuntime {
+		runtimeMetrics = getRuntimeMetrics(prometheus.DefaultRegisterer)
+		// available when promhttp.Handler() is activated
+		runtimeMetrics = append(runtimeMetrics, []string{
+			"process_network_receive_bytes_total",
+			"process_network_transmit_bytes_total",
+		}...)
+	} else {
+		runtimeMetrics = []string{}
+	}
+
+	var b bytes.Buffer
+	err := tmpl.ExecuteTemplate(&b, "metrics.gotpl", struct {
+		Metrics        []*metrics.Metric
+		RuntimeMetrics []string
+	}{
+		Metrics:        m.Metrics,
+		RuntimeMetrics: runtimeMetrics,
+	})
+
+	if err != nil {
+		return "", err
+	}
+	return b.String(), nil
+}
+
+// sortMetrics sorts the given slice of metrics by their subsystem and name.
+// Metrics are first sorted by their subsystem, and then by their name within each subsystem.
+func sortMetrics(metrics []*metrics.Metric) {
+	sort.Slice(metrics, func(i, j int) bool {
+		if metrics[i].Subsystem == metrics[j].Subsystem {
+			return metrics[i].Name < metrics[j].Name
+		}
+		return metrics[i].Subsystem < metrics[j].Subsystem
+	})
+}
+
+// getRuntimeMetrics retrieves the list of runtime metrics from the Prometheus library.
+func getRuntimeMetrics(reg prometheus.Registerer) []string {
+	var runtimeMetrics []string
+
+	// hacks to get the runtime metrics from prometheus library
+	// safe to do because it's a just a documentation generator
+	values := reflect.ValueOf(reg).Elem().FieldByName("dimHashesByName")
+	values = reflect.NewAt(values.Type(), unsafe.Pointer(values.UnsafeAddr())).Elem()
+
+	switch v := values.Interface().(type) {
+	case map[string]uint64:
+		for k := range v {
+			if !strings.HasPrefix(k, "external_dns") {
+				runtimeMetrics = append(runtimeMetrics, k)
+			}
+		}
+	}
+	sort.Strings(runtimeMetrics)
+	return runtimeMetrics
+}
diff --git a/internal/gen/docs/metrics/templates/metrics.gotpl b/internal/gen/docs/metrics/templates/metrics.gotpl
new file mode 100644
index 00000000..9ca9769e
--- /dev/null
+++ b/internal/gen/docs/metrics/templates/metrics.gotpl
@@ -0,0 +1,37 @@
+# Available Metrics
+
+<!-- THIS FILE MUST NOT BE EDITED BY HAND -->
+<!-- ON NEW METRIC ADDED PLEASE RUN 'make generate-metrics-documentation' -->
+<!-- markdownlint-disable MD013 -->
+
+All metrics available for scraping are exposed on the {{backtick 1}}/metrics{{backtick 1}} endpoint.
+The metrics are in the Prometheus exposition format.
+
+To access the metrics:
+
+{{backtick 3}}sh
+curl https://localhost:7979/metrics
+{{backtick 3}}
+
+## Supported Metrics
+
+> Full metric name is constructed as follows:
+> {{backtick 1}}external_dns_<subsystem>_<name>{{backtick 1}}
+
+| Name                             | Metric Type | Subsystem   |  Help                                                 |
+|:---------------------------------|:------------|:------------|:------------------------------------------------------|
+{{- range .Metrics }}
+| {{ .Name }} | {{ .Type | capitalize }} | {{ .Subsystem }} | {{ .Help }} |
+{{- end }}
+
+## Available Go Runtime Metrics
+
+> The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent.
+
+{{ if .RuntimeMetrics -}}
+| Name                  |
+|:----------------------|
+{{- range .RuntimeMetrics }}
+| {{ . }} |
+{{- end -}}
+{{- end -}}
diff --git a/internal/gen/docs/utils/utils.go b/internal/gen/docs/utils/utils.go
new file mode 100644
index 00000000..5aff6466
--- /dev/null
+++ b/internal/gen/docs/utils/utils.go
@@ -0,0 +1,50 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package utils
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"text/template"
+
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+func WriteToFile(filename string, content string) error {
+	file, fileErr := os.Create(filename)
+	if fileErr != nil {
+		_ = fmt.Errorf("failed to create file: %v", fileErr)
+	}
+	defer file.Close()
+	_, writeErr := file.WriteString(content)
+	if writeErr != nil {
+		_ = fmt.Errorf("failed to write to file: %s", filename)
+	}
+	return nil
+}
+
+// FuncMap returns a mapping of all of the functions that Engine has.
+func FuncMap() template.FuncMap {
+	return template.FuncMap{
+		"backtick": func(times int) string {
+			return strings.Repeat("`", times)
+		},
+		"capitalize": cases.Title(language.English, cases.Compact).String,
+	}
+}
diff --git a/main.go b/main.go
index bc479075..25f2b5c1 100644
--- a/main.go
+++ b/main.go
@@ -39,6 +39,7 @@ import (
 	"sigs.k8s.io/external-dns/endpoint"
 	"sigs.k8s.io/external-dns/pkg/apis/externaldns"
 	"sigs.k8s.io/external-dns/pkg/apis/externaldns/validation"
+	"sigs.k8s.io/external-dns/pkg/metrics"
 	"sigs.k8s.io/external-dns/plan"
 	"sigs.k8s.io/external-dns/provider"
 	"sigs.k8s.io/external-dns/provider/akamai"
@@ -449,9 +450,13 @@ func handleSigterm(cancel func()) {
 func serveMetrics(address string) {
 	http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusOK)
-		w.Write([]byte("OK"))
+		_, _ = w.Write([]byte("OK"))
 	})
 
+	log.Debugf("serving 'healthz' on 'localhost:%s/healthz'", address)
+	log.Debugf("serving 'metrics' on 'localhost:%s/metrics'", address)
+	log.Debugf("registered '%d' metrics", len(metrics.RegisterMetric.Metrics))
+
 	http.Handle("/metrics", promhttp.Handler())
 
 	log.Fatal(http.ListenAndServe(address, nil))
diff --git a/mkdocs.yml b/mkdocs.yml
index c276dc74..b491f783 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -27,6 +27,7 @@ nav:
   - Advanced Topics:
     - Initial Design: docs/initial-design.md
     - Leader Election: docs/proposal/001-leader-election.md
+    - Monitoring: docs/monitoring/*
     - MultiTarget: docs/proposal/multi-target.md
     - NAT64: docs/nat64.md
     - Rate Limits: docs/rate-limits.md
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
new file mode 100644
index 00000000..028e78aa
--- /dev/null
+++ b/pkg/metrics/metrics.go
@@ -0,0 +1,77 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package metrics
+
+import (
+	"runtime"
+
+	"github.com/prometheus/client_golang/prometheus"
+	log "github.com/sirupsen/logrus"
+
+	cfg "sigs.k8s.io/external-dns/pkg/apis/externaldns"
+)
+
+var (
+	RegisterMetric = NewMetricsRegister()
+)
+
+func NewMetricsRegister() *MetricRegistry {
+	reg := prometheus.WrapRegistererWith(
+		prometheus.Labels{
+			"version":    cfg.Version,
+			"arch":       runtime.GOARCH,
+			"go_version": runtime.Version(),
+		},
+		prometheus.DefaultRegisterer)
+	return &MetricRegistry{
+		Registerer: reg,
+		Metrics:    []*Metric{},
+		mName:      make(map[string]bool),
+	}
+}
+
+// MustRegister registers a metric if it hasn't been registered yet.
+//
+// Usage: MustRegister(...)
+// Example:
+//
+//	func init() {
+//	     metrics.RegisterMetric.MustRegister(errorsTotal)
+//	}
+func (m *MetricRegistry) MustRegister(cs IMetric) {
+	switch v := cs.(type) {
+	case CounterMetric, GaugeMetric, CounterVecMetric:
+		if _, exists := m.mName[cs.Get().FQDN]; exists {
+			return
+		} else {
+			m.mName[cs.Get().FQDN] = true
+		}
+		m.Metrics = append(m.Metrics, cs.Get())
+		switch metric := v.(type) {
+		case CounterMetric:
+			m.Registerer.MustRegister(metric.Counter)
+		case GaugeMetric:
+			m.Registerer.MustRegister(metric.Gauge)
+		case CounterVecMetric:
+			m.Registerer.MustRegister(metric.CounterVec)
+		}
+		log.Debugf("Register metric: %s", cs.Get().FQDN)
+	default:
+		log.Warnf("Unsupported metric type: %T", v)
+		return
+	}
+}
diff --git a/pkg/metrics/models.go b/pkg/metrics/models.go
new file mode 100644
index 00000000..8878a588
--- /dev/null
+++ b/pkg/metrics/models.go
@@ -0,0 +1,111 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package metrics
+
+import (
+	"fmt"
+
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+type MetricRegistry struct {
+	Registerer prometheus.Registerer
+	Metrics    []*Metric
+	mName      map[string]bool
+}
+
+type Metric struct {
+	Type      string
+	Namespace string
+	Subsystem string
+	Name      string
+	Help      string
+	FQDN      string
+}
+
+type IMetric interface {
+	Get() *Metric
+}
+
+type GaugeMetric struct {
+	Metric
+	Gauge prometheus.Gauge
+}
+
+func (g GaugeMetric) Get() *Metric {
+	return &g.Metric
+}
+
+type CounterMetric struct {
+	Metric
+	Counter prometheus.Counter
+}
+
+func (g CounterMetric) Get() *Metric {
+	return &g.Metric
+}
+
+type CounterVecMetric struct {
+	Metric
+	CounterVec *prometheus.CounterVec
+}
+
+func (g CounterVecMetric) Get() *Metric {
+	return &g.Metric
+}
+
+func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric {
+	return GaugeMetric{
+		Metric: Metric{
+			Type:      "gauge",
+			Name:      opts.Name,
+			FQDN:      fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name),
+			Namespace: opts.Namespace,
+			Subsystem: opts.Subsystem,
+			Help:      opts.Help,
+		},
+		Gauge: prometheus.NewGauge(opts),
+	}
+}
+
+func NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric {
+	return CounterMetric{
+		Metric: Metric{
+			Type:      "counter",
+			Name:      opts.Name,
+			FQDN:      fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name),
+			Namespace: opts.Namespace,
+			Subsystem: opts.Subsystem,
+			Help:      opts.Help,
+		},
+		Counter: prometheus.NewCounter(opts),
+	}
+}
+
+func NewCounterVecWithOpts(opts prometheus.CounterOpts, labelNames []string) CounterVecMetric {
+	return CounterVecMetric{
+		Metric: Metric{
+			Type:      "counter",
+			Name:      opts.Name,
+			FQDN:      fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name),
+			Namespace: opts.Namespace,
+			Subsystem: opts.Subsystem,
+			Help:      opts.Help,
+		},
+		CounterVec: prometheus.NewCounterVec(opts, labelNames),
+	}
+}
diff --git a/provider/aws/aws.go b/provider/aws/aws.go
index 26ad8112..0daa6fed 100644
--- a/provider/aws/aws.go
+++ b/provider/aws/aws.go
@@ -21,6 +21,7 @@ import (
 	"errors"
 	"fmt"
 	"regexp"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -62,6 +63,9 @@ const (
 	providerSpecificMultiValueAnswer           = "aws/multi-value-answer"
 	providerSpecificHealthCheckID              = "aws/health-check-id"
 	sameZoneAlias                              = "same-zone"
+	// Currently supported up to 10 health checks or hosted zones.
+	// https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListTagsForResources.html#API_ListTagsForResources_RequestSyntax
+	batchSize = 10
 )
 
 // see elb: https://docs.aws.amazon.com/general/latest/gr/elb.html
@@ -205,7 +209,7 @@ type Route53API interface {
 	ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
 	CreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error)
 	ListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error)
-	ListTagsForResource(ctx context.Context, input *route53.ListTagsForResourceInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourceOutput, error)
+	ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error)
 }
 
 // Route53Change wrapper to handle ownership relation throughout the provider implementation
@@ -231,6 +235,29 @@ func (cs Route53Changes) Route53Changes() []route53types.Change {
 	return ret
 }
 
+type zoneTags map[string]map[string]string
+
+// filterZonesByTags filters the provided zones map by matching the tags against the provider's zoneTagFilter.
+// It removes any zones from the map that do not match the filter criteria.
+func (z zoneTags) filterZonesByTags(p *AWSProvider, zones map[string]*profiledZone) {
+	for zone, tags := range z {
+		if !p.zoneTagFilter.Match(tags) {
+			delete(zones, zone)
+		}
+	}
+}
+
+// append adds tags to the ZoneTags for a given zoneID.
+func (z zoneTags) append(id string, tags []route53types.Tag) {
+	zoneId := fmt.Sprintf("/hostedzone/%s", id)
+	if _, exists := z[zoneId]; !exists {
+		z[zoneId] = make(map[string]string)
+	}
+	for _, tag := range tags {
+		z[zoneId][*tag.Key] = *tag.Value
+	}
+}
+
 type zonesListCache struct {
 	age      time.Time
 	duration time.Duration
@@ -328,7 +355,6 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro
 	zones := make(map[string]*profiledZone)
 
 	for profile, client := range p.clients {
-		var tagErr error
 		paginator := route53.NewListHostedZonesPaginator(client, &route53.ListHostedZonesInput{})
 
 		for paginator.HasMorePages() {
@@ -342,6 +368,7 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro
 				// nothing to do here. Falling through to general error handling
 				return nil, provider.NewSoftError(fmt.Errorf("failed to list hosted zones: %w", err))
 			}
+			var zonesToTagFilter []string
 			for _, zone := range resp.HostedZones {
 				if !p.zoneIDFilter.Match(*zone.Id) {
 					continue
@@ -360,16 +387,8 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro
 					}
 				}
 
-				// Only fetch tags if a tag filter was specified
 				if !p.zoneTagFilter.IsEmpty() {
-					tags, err := p.tagsForZone(ctx, *zone.Id, profile)
-					if err != nil {
-						tagErr = err
-						break
-					}
-					if !p.zoneTagFilter.Match(tags) {
-						continue
-					}
+					zonesToTagFilter = append(zonesToTagFilter, cleanZoneID(*zone.Id))
 				}
 
 				zones[*zone.Id] = &profiledZone{
@@ -377,14 +396,21 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro
 					zone:    &zone,
 				}
 			}
-		}
-		if tagErr != nil {
-			return nil, provider.NewSoftErrorf("failed to list zones tags: %w", tagErr)
+
+			if len(zonesToTagFilter) > 0 {
+				if zTags, err := p.tagsForZone(ctx, zonesToTagFilter, profile); err != nil {
+					return nil, provider.NewSoftErrorf("failed to list tags for zones %w", err)
+				} else {
+					zTags.filterZonesByTags(p, zones)
+				}
+			}
 		}
 	}
 
-	for _, zone := range zones {
-		log.Debugf("Considering zone: %s (domain: %s)", *zone.zone.Id, *zone.zone.Name)
+	if log.IsLevelEnabled(log.DebugLevel) {
+		for _, zone := range zones {
+			log.Debugf("Considering zone: %s (domain: %s)", *zone.zone.Id, *zone.zone.Name)
+		}
 	}
 
 	if p.zonesCache.duration > time.Duration(0) {
@@ -483,7 +509,7 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon
 						ttl = recordTTL
 					}
 					ep := endpoint.
-						NewEndpointWithTTL(name, endpoint.RecordTypeA, ttl, *r.AliasTarget.DNSName).
+						NewEndpointWithTTL(name, string(r.Type), ttl, *r.AliasTarget.DNSName).
 						WithProviderSpecific(providerSpecificEvaluateTargetHealth, fmt.Sprintf("%t", r.AliasTarget.EvaluateTargetHealth)).
 						WithProviderSpecific(providerSpecificAlias, "true")
 					newEndpoints = append(newEndpoints, ep)
@@ -636,6 +662,7 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
 	}
 
 	var failedZones []string
+	debugLevel := log.DebugLevel
 	for z, cs := range changesByZone {
 		log := log.WithFields(log.Fields{
 			"zoneName": *zones[z].zone.Name,
@@ -678,10 +705,11 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
 
 					if len(changesByOwnership) > 1 {
 						log.Debug("Trying to submit change sets one-by-one instead")
-
 						for _, changes := range changesByOwnership {
-							for _, c := range changes {
-								log.Debugf("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)
+							if log.Logger.IsLevelEnabled(debugLevel) {
+								for _, c := range changes {
+									log.Debugf("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)
+								}
 							}
 							params.ChangeBatch = &route53types.ChangeBatch{
 								Changes: changes.Route53Changes(),
@@ -729,17 +757,8 @@ func (p *AWSProvider) newChanges(action route53types.ChangeAction, endpoints []*
 	changes := make(Route53Changes, 0, len(endpoints))
 
 	for _, endpoint := range endpoints {
-		change, dualstack := p.newChange(action, endpoint)
+		change := p.newChange(action, endpoint)
 		changes = append(changes, change)
-		if dualstack {
-			// make a copy of change, modify RRS type to AAAA, then add new change
-			rrs := *change.ResourceRecordSet
-			change2 := &Route53Change{
-				Change: route53types.Change{Action: change.Action, ResourceRecordSet: &rrs},
-			}
-			change2.ResourceRecordSet.Type = route53types.RRTypeAaaa
-			changes = append(changes, change2)
-		}
 	}
 
 	return changes
@@ -751,13 +770,17 @@ func (p *AWSProvider) newChanges(action route53types.ChangeAction, endpoints []*
 // Example: CNAME endpoints pointing to ELBs will have a `alias` provider-specific property
 // added to match the endpoints generated from existing alias records in Route53.
 func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
+	// Holds CNAME targets that we will treat as Alias records. Such records are
+	// hard coded to 'A' type aliases but we also need their 'AAAA' counterparts.
+	var aliasCnameAaaaEndpoints []*endpoint.Endpoint
+
 	for _, ep := range endpoints {
 		alias := false
 
 		if aliasString, ok := ep.GetProviderSpecificProperty(providerSpecificAlias); ok {
 			alias = aliasString == "true"
 			if alias {
-				if ep.RecordType != endpoint.RecordTypeA && ep.RecordType != endpoint.RecordTypeCNAME {
+				if !slices.Contains([]string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, ep.RecordType) {
 					ep.DeleteProviderSpecificProperty(providerSpecificAlias)
 				}
 			} else {
@@ -776,7 +799,6 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi
 		}
 
 		if alias {
-			ep.RecordType = endpoint.RecordTypeA
 			if ep.RecordTTL.IsConfigured() {
 				log.Debugf("Modifying endpoint: %v, setting ttl=%v", ep, recordTTL)
 				ep.RecordTTL = recordTTL
@@ -788,18 +810,35 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi
 			} else {
 				ep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(p.evaluateTargetHealth))
 			}
+
+			if ep.RecordType == endpoint.RecordTypeCNAME {
+				// This needs to match two records from Route53, one alias for 'A' (IPv4)
+				// and one alias for 'AAAA' (IPv6).
+				aliasCnameAaaaEndpoints = append(aliasCnameAaaaEndpoints, &endpoint.Endpoint{
+					DNSName:          ep.DNSName,
+					Targets:          ep.Targets,
+					RecordType:       endpoint.RecordTypeAAAA,
+					RecordTTL:        ep.RecordTTL,
+					Labels:           ep.Labels,
+					ProviderSpecific: ep.ProviderSpecific,
+					SetIdentifier:    ep.SetIdentifier,
+				})
+				ep.RecordType = endpoint.RecordTypeA
+			}
 		} else {
 			ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
 		}
 	}
+
+	endpoints = append(endpoints, aliasCnameAaaaEndpoints...)
 	return endpoints, nil
 }
 
-// newChange returns a route53 Change and a boolean indicating if there should also be a change to a AAAA record
+// newChange returns a route53 Change
 // returned Change is based on the given record by the given action, e.g.
 // action=ChangeActionCreate returns a change for creation of the record and
 // action=ChangeActionDelete returns a change for deletion of the record.
-func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.Endpoint) (*Route53Change, bool) {
+func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.Endpoint) *Route53Change {
 	change := &Route53Change{
 		Change: route53types.Change{
 			Action: action,
@@ -808,17 +847,12 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E
 			},
 		},
 	}
-	dualstack := false
+	change.ResourceRecordSet.Type = route53types.RRType(ep.RecordType)
 	if targetHostedZone := isAWSAlias(ep); targetHostedZone != "" {
 		evalTargetHealth := p.evaluateTargetHealth
 		if prop, ok := ep.GetProviderSpecificProperty(providerSpecificEvaluateTargetHealth); ok {
 			evalTargetHealth = prop == "true"
 		}
-		// If the endpoint has a Dualstack label, append a change for AAAA record as well.
-		if val, ok := ep.Labels[endpoint.DualstackLabelKey]; ok {
-			dualstack = val == "true"
-		}
-		change.ResourceRecordSet.Type = route53types.RRTypeA
 		change.ResourceRecordSet.AliasTarget = &route53types.AliasTarget{
 			DNSName:              aws.String(ep.Targets[0]),
 			HostedZoneId:         aws.String(cleanZoneID(targetHostedZone)),
@@ -827,7 +861,6 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E
 		change.sizeBytes += len([]byte(ep.Targets[0]))
 		change.sizeValues += 1
 	} else {
-		change.ResourceRecordSet.Type = route53types.RRType(ep.RecordType)
 		if !ep.RecordTTL.IsConfigured() {
 			change.ResourceRecordSet.TTL = aws.Int64(recordTTL)
 		} else {
@@ -899,7 +932,7 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E
 		change.OwnedRecord = ownedRecord
 	}
 
-	return change, dualstack
+	return change
 }
 
 // searches for `changes` that are contained in `queue` and returns the `changes` separated by whether they were found in the queue (`foundChanges`) or not (`notFoundChanges`)
@@ -940,23 +973,29 @@ func groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route5
 	return changesByOwnership
 }
 
-func (p *AWSProvider) tagsForZone(ctx context.Context, zoneID string, profile string) (map[string]string, error) {
+func (p *AWSProvider) tagsForZone(ctx context.Context, zoneIDs []string, profile string) (zoneTags, error) {
 	client := p.clients[profile]
 
-	// TODO: this will make single API request for each zone, which consumes API requests
-	// more effective way is to batch requests https://github.com/aws/aws-sdk-go-v2/blob/ed8a3caa0df9ce36a5b60aebeee201187098d205/service/route53/api_op_ListTagsForResources.go#L37
-	response, err := client.ListTagsForResource(ctx, &route53.ListTagsForResourceInput{
-		ResourceType: route53types.TagResourceTypeHostedzone,
-		ResourceId:   aws.String(cleanZoneID(zoneID)),
-	})
-	if err != nil {
-		return nil, provider.NewSoftErrorf("failed to list tags for zone %s: %w", zoneID, err)
-	}
-	tagMap := map[string]string{}
-	for _, tag := range response.ResourceTagSet.Tags {
-		tagMap[*tag.Key] = *tag.Value
+	result := zoneTags{}
+
+	for i := 0; i < len(zoneIDs); i += batchSize {
+		batch := zoneIDs[i:min(i+batchSize, len(zoneIDs))]
+		if len(batch) == 0 {
+			break
+		}
+		response, err := client.ListTagsForResources(ctx, &route53.ListTagsForResourcesInput{
+			ResourceType: route53types.TagResourceTypeHostedzone,
+			ResourceIds:  batch,
+		})
+		if err != nil {
+			return nil, provider.NewSoftErrorf("failed to list tags for zones. %v", err)
+		}
+
+		for _, res := range response.ResourceTagSets {
+			result.append(*res.ResourceId, res.Tags)
+		}
 	}
-	return tagMap, nil
+	return result, nil
 }
 
 // count bytes for all changes values
@@ -1142,7 +1181,7 @@ func useAlias(ep *endpoint.Endpoint, preferCNAME bool) bool {
 // and (if so) returns the target hosted zone ID
 func isAWSAlias(ep *endpoint.Endpoint) string {
 	isAlias, exists := ep.GetProviderSpecificProperty(providerSpecificAlias)
-	if exists && isAlias == "true" && ep.RecordType == endpoint.RecordTypeA && len(ep.Targets) > 0 {
+	if exists && isAlias == "true" && slices.Contains([]string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA}, ep.RecordType) && len(ep.Targets) > 0 {
 		// alias records can only point to canonical hosted zones (e.g. to ELBs) or other records in the same zone
 
 		if hostedZoneID, ok := ep.GetProviderSpecificProperty(providerSpecificTargetHostedZone); ok {
diff --git a/provider/awssd/aws_sd.go b/provider/awssd/aws_sd.go
index babe4859..95ecda40 100644
--- a/provider/awssd/aws_sd.go
+++ b/provider/awssd/aws_sd.go
@@ -222,13 +222,6 @@ func (p *AWSSDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes)
 		return err
 	}
 
-	// Deletes must be executed first to support update case.
-	// When just list of targets is updated `[1.2.3.4] -> [1.2.3.4, 1.2.3.5]` it is translated to:
-	// ```
-	// deletes = [1.2.3.4]
-	// creates = [1.2.3.4, 1.2.3.5]
-	// ```
-	// then when deletes are executed after creates it will miss the `1.2.3.4` instance.
 	err = p.submitDeletes(ctx, namespaces, changes.Delete)
 	if err != nil {
 		return err
@@ -252,7 +245,20 @@ func (p *AWSSDProvider) updatesToCreates(changes *plan.Changes) (creates []*endp
 		current := updateNewMap[old.DNSName]
 
 		if !old.Targets.Same(current.Targets) {
-			// when targets differ the old instances need to be de-registered first
+			currentTargetsMap := make(map[string]struct{}, len(current.Targets))
+			for _, newTarget := range current.Targets {
+				currentTargetsMap[newTarget] = struct{}{}
+			}
+
+			// If targets changed, only deregister removed targets (i.e. in `UpdateOld` but not in `UpdateNew`)
+			targetsToRemove := make(endpoint.Targets, 0)
+			for _, oldTarget := range old.Targets {
+				if _, found := currentTargetsMap[oldTarget]; !found {
+					targetsToRemove = append(targetsToRemove, oldTarget)
+				}
+			}
+
+			old.Targets = targetsToRemove
 			deletes = append(deletes, old)
 		}
 
diff --git a/provider/cached_provider.go b/provider/cached_provider.go
index 6009c77d..8f86d704 100644
--- a/provider/cached_provider.go
+++ b/provider/cached_provider.go
@@ -13,22 +13,23 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 package provider
 
 import (
 	"context"
-	"sync"
 	"time"
 
 	"github.com/prometheus/client_golang/prometheus"
 	log "github.com/sirupsen/logrus"
 
 	"sigs.k8s.io/external-dns/endpoint"
+	"sigs.k8s.io/external-dns/pkg/metrics"
 	"sigs.k8s.io/external-dns/plan"
 )
 
 var (
-	cachedRecordsCallsTotal = prometheus.NewCounterVec(
+	cachedRecordsCallsTotal = metrics.NewCounterVecWithOpts(
 		prometheus.CounterOpts{
 			Namespace: "external_dns",
 			Subsystem: "provider",
@@ -39,7 +40,7 @@ var (
 			"from_cache",
 		},
 	)
-	cachedApplyChangesCallsTotal = prometheus.NewCounter(
+	cachedApplyChangesCallsTotal = metrics.NewCounterWithOpts(
 		prometheus.CounterOpts{
 			Namespace: "external_dns",
 			Subsystem: "provider",
@@ -47,10 +48,13 @@ var (
 			Help:      "Number of calls to the provider cache ApplyChanges.",
 		},
 	)
-
-	registerCacheProviderMetrics = sync.Once{}
 )
 
+func init() {
+	metrics.RegisterMetric.MustRegister(cachedRecordsCallsTotal)
+	metrics.RegisterMetric.MustRegister(cachedApplyChangesCallsTotal)
+}
+
 type CachedProvider struct {
 	Provider
 	RefreshDelay time.Duration
@@ -59,9 +63,6 @@ type CachedProvider struct {
 }
 
 func NewCachedProvider(provider Provider, refreshDelay time.Duration) *CachedProvider {
-	registerCacheProviderMetrics.Do(func() {
-		prometheus.MustRegister(cachedRecordsCallsTotal)
-	})
 	return &CachedProvider{
 		Provider:     provider,
 		RefreshDelay: refreshDelay,
@@ -78,10 +79,10 @@ func (c *CachedProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, err
 		}
 		c.cache = records
 		c.lastRead = time.Now()
-		cachedRecordsCallsTotal.WithLabelValues("false").Inc()
+		cachedRecordsCallsTotal.CounterVec.WithLabelValues("false").Inc()
 	} else {
 		log.Debug("Records cache provider: using records list from cache")
-		cachedRecordsCallsTotal.WithLabelValues("true").Inc()
+		cachedRecordsCallsTotal.CounterVec.WithLabelValues("true").Inc()
 	}
 	return c.cache, nil
 }
@@ -91,7 +92,7 @@ func (c *CachedProvider) ApplyChanges(ctx context.Context, changes *plan.Changes
 		return nil
 	}
 	c.Reset()
-	cachedApplyChangesCallsTotal.Inc()
+	cachedApplyChangesCallsTotal.Counter.Inc()
 	return c.Provider.ApplyChanges(ctx, changes)
 }
 
diff --git a/provider/webhook/webhook.go b/provider/webhook/webhook.go
index 8cae0850..1a1558d4 100644
--- a/provider/webhook/webhook.go
+++ b/provider/webhook/webhook.go
@@ -25,11 +25,12 @@ import (
 	"net/url"
 
 	"sigs.k8s.io/external-dns/endpoint"
+	"sigs.k8s.io/external-dns/pkg/metrics"
 	"sigs.k8s.io/external-dns/plan"
 	"sigs.k8s.io/external-dns/provider"
 	webhookapi "sigs.k8s.io/external-dns/provider/webhook/api"
 
-	backoff "github.com/cenkalti/backoff/v4"
+	"github.com/cenkalti/backoff/v4"
 	"github.com/prometheus/client_golang/prometheus"
 	log "github.com/sirupsen/logrus"
 )
@@ -40,7 +41,7 @@ const (
 )
 
 var (
-	recordsErrorsGauge = prometheus.NewGauge(
+	recordsErrorsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -48,7 +49,7 @@ var (
 			Help:      "Errors with Records method",
 		},
 	)
-	recordsRequestsGauge = prometheus.NewGauge(
+	recordsRequestsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -56,7 +57,7 @@ var (
 			Help:      "Requests with Records method",
 		},
 	)
-	applyChangesErrorsGauge = prometheus.NewGauge(
+	applyChangesErrorsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -64,7 +65,7 @@ var (
 			Help:      "Errors with ApplyChanges method",
 		},
 	)
-	applyChangesRequestsGauge = prometheus.NewGauge(
+	applyChangesRequestsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -72,7 +73,7 @@ var (
 			Help:      "Requests with ApplyChanges method",
 		},
 	)
-	adjustEndpointsErrorsGauge = prometheus.NewGauge(
+	adjustEndpointsErrorsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -80,7 +81,7 @@ var (
 			Help:      "Errors with AdjustEndpoints method",
 		},
 	)
-	adjustEndpointsRequestsGauge = prometheus.NewGauge(
+	adjustEndpointsRequestsGauge = metrics.NewGaugeWithOpts(
 		prometheus.GaugeOpts{
 			Namespace: "external_dns",
 			Subsystem: "webhook_provider",
@@ -97,12 +98,12 @@ type WebhookProvider struct {
 }
 
 func init() {
-	prometheus.MustRegister(recordsErrorsGauge)
-	prometheus.MustRegister(recordsRequestsGauge)
-	prometheus.MustRegister(applyChangesErrorsGauge)
-	prometheus.MustRegister(applyChangesRequestsGauge)
-	prometheus.MustRegister(adjustEndpointsErrorsGauge)
-	prometheus.MustRegister(adjustEndpointsRequestsGauge)
+	metrics.RegisterMetric.MustRegister(recordsErrorsGauge)
+	metrics.RegisterMetric.MustRegister(recordsRequestsGauge)
+	metrics.RegisterMetric.MustRegister(applyChangesErrorsGauge)
+	metrics.RegisterMetric.MustRegister(applyChangesRequestsGauge)
+	metrics.RegisterMetric.MustRegister(adjustEndpointsErrorsGauge)
+	metrics.RegisterMetric.MustRegister(adjustEndpointsRequestsGauge)
 }
 
 func NewWebhookProvider(u string) (*WebhookProvider, error) {
@@ -160,26 +161,26 @@ func NewWebhookProvider(u string) (*WebhookProvider, error) {
 
 // Records will make a GET call to remoteServerURL/records and return the results
 func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
-	recordsRequestsGauge.Inc()
+	recordsRequestsGauge.Gauge.Inc()
 	u := p.remoteServerURL.JoinPath("records").String()
 
 	req, err := http.NewRequest("GET", u, nil)
 	if err != nil {
-		recordsErrorsGauge.Inc()
+		recordsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to create request: %s", err.Error())
 		return nil, err
 	}
 	req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)
 	resp, err := p.client.Do(req)
 	if err != nil {
-		recordsErrorsGauge.Inc()
+		recordsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to perform request: %s", err.Error())
 		return nil, err
 	}
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		recordsErrorsGauge.Inc()
+		recordsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to get records with code %d", resp.StatusCode)
 		err := fmt.Errorf("failed to get records with code %d", resp.StatusCode)
 		if isRetryableError(resp.StatusCode) {
@@ -190,7 +191,7 @@ func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, err
 
 	endpoints := []*endpoint.Endpoint{}
 	if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
-		recordsErrorsGauge.Inc()
+		recordsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to decode response body: %s", err.Error())
 		return nil, err
 	}
@@ -199,19 +200,19 @@ func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, err
 
 // ApplyChanges will make a POST to remoteServerURL/records with the changes
 func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
-	applyChangesRequestsGauge.Inc()
+	applyChangesRequestsGauge.Gauge.Inc()
 	u := p.remoteServerURL.JoinPath("records").String()
 
 	b := new(bytes.Buffer)
 	if err := json.NewEncoder(b).Encode(changes); err != nil {
-		applyChangesErrorsGauge.Inc()
+		applyChangesErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to encode changes: %s", err.Error())
 		return err
 	}
 
 	req, err := http.NewRequest("POST", u, b)
 	if err != nil {
-		applyChangesErrorsGauge.Inc()
+		applyChangesErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to create request: %s", err.Error())
 		return err
 	}
@@ -220,14 +221,14 @@ func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes
 
 	resp, err := p.client.Do(req)
 	if err != nil {
-		applyChangesErrorsGauge.Inc()
+		applyChangesErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to perform request: %s", err.Error())
 		return err
 	}
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusNoContent {
-		applyChangesErrorsGauge.Inc()
+		applyChangesErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to apply changes with code %d", resp.StatusCode)
 		err := fmt.Errorf("failed to apply changes with code %d", resp.StatusCode)
 		if isRetryableError(resp.StatusCode) {
@@ -242,25 +243,25 @@ func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes
 // based on a provider specific requirement.
 // This method returns an empty slice in case there is a technical error on the provider's side so that no endpoints will be considered.
 func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
-	adjustEndpointsRequestsGauge.Inc()
+	adjustEndpointsRequestsGauge.Gauge.Inc()
 	endpoints := []*endpoint.Endpoint{}
 	u, err := url.JoinPath(p.remoteServerURL.String(), "adjustendpoints")
 	if err != nil {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to join path, %s", err)
 		return nil, err
 	}
 
 	b := new(bytes.Buffer)
 	if err := json.NewEncoder(b).Encode(e); err != nil {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to encode endpoints, %s", err)
 		return nil, err
 	}
 
 	req, err := http.NewRequest("POST", u, b)
 	if err != nil {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to create new HTTP request, %s", err)
 		return nil, err
 	}
@@ -270,14 +271,14 @@ func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.En
 
 	resp, err := p.client.Do(req)
 	if err != nil {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed executing http request, %s", err)
 		return nil, err
 	}
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to AdjustEndpoints with code %d", resp.StatusCode)
 		err := fmt.Errorf("failed to AdjustEndpoints with code  %d", resp.StatusCode)
 		if isRetryableError(resp.StatusCode) {
@@ -287,7 +288,7 @@ func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.En
 	}
 
 	if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
-		adjustEndpointsErrorsGauge.Inc()
+		adjustEndpointsErrorsGauge.Gauge.Inc()
 		log.Debugf("Failed to decode response body: %s", err.Error())
 		return nil, err
 	}
diff --git a/scripts/helm-tools.sh b/scripts/helm-tools.sh
index f723eb74..0d635a6c 100755
--- a/scripts/helm-tools.sh
+++ b/scripts/helm-tools.sh
@@ -14,6 +14,8 @@ set -e
 # scripts/helm-tools.sh --schema
 # scripts/helm-tools.sh --lint
 # scripts/helm-tools.sh --docs
+# scripts/helm-tools.sh --helm-template
+# scripts/helm-tools.sh --helm-unittest
 
 show_help() {
 cat << EOF
@@ -26,6 +28,8 @@ Usage: $(basename "$0") <options>
     -i, --install       Install required tooling
     -l, --lint          Lint chart
     -s, --schema        Generate schema
+    --helm-unittest     Run helm unittest(s)
+    --helm-template     Run helm template
     --show-docs         Show available documentation
 EOF
 }
@@ -34,8 +38,13 @@ install() {
   if [[ -x $(which helm) ]]; then
       echo "installing https://github.com/losisin/helm-values-schema-json.git plugin"
       helm plugin install https://github.com/losisin/helm-values-schema-json.git | true
+      helm plugin update schema
       helm plugin list | grep "schema"
 
+      helm plugin install https://github.com/helm-unittest/helm-unittest.git | true
+      helm plugin update unittest
+      helm plugin list | grep "unittest"
+
       echo "installing helm-docs"
       go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest | true
 
@@ -84,6 +93,16 @@ helm_docs() {
   helm-docs
 }
 
+helm_unittest() {
+  helm unittest -f 'tests/*_test.yaml' --color charts/external-dns
+}
+
+helm_template() {
+  helm template external-dns charts/external-dns \
+		--output-dir _scratch \
+		-n kube-system
+}
+
 show_docs() {
   open "https://github.com/losisin/helm-values-schema-json?tab=readme-ov-file"
 }
@@ -93,6 +112,12 @@ function main() {
     --show-docs)
       show_docs
       ;;
+    --helm-unittest)
+      helm_unittest
+      ;;
+    --helm-template)
+      helm_unittest
+      ;;
     -d|--diff)
       diff_schema
       ;;
diff --git a/source/gateway.go b/source/gateway.go
index 97801bf6..a8741ee9 100644
--- a/source/gateway.go
+++ b/source/gateway.go
@@ -45,10 +45,6 @@ import (
 const (
 	gatewayGroup = "gateway.networking.k8s.io"
 	gatewayKind  = "Gateway"
-	// gatewayAPIDualstackAnnotationKey is the annotation used for determining if a Gateway Route is dualstack
-	gatewayAPIDualstackAnnotationKey = "external-dns.alpha.kubernetes.io/dualstack"
-	// gatewayAPIDualstackAnnotationValue is the value of the Gateway Route dualstack annotation that indicates it is dualstack
-	gatewayAPIDualstackAnnotationValue = "true"
 )
 
 type gatewayRoute interface {
@@ -241,7 +237,6 @@ func (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpo
 		for host, targets := range hostTargets {
 			routeEndpoints = append(routeEndpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)
 		}
-		setDualstackLabel(rt, routeEndpoints)
 		log.Debugf("Endpoints generated from %s %s/%s: %v", src.rtKind, meta.Namespace, meta.Name, routeEndpoints)
 
 		endpoints = append(endpoints, routeEndpoints...)
@@ -618,13 +613,3 @@ func selectorsEqual(a, b labels.Selector) bool {
 	}
 	return true
 }
-
-func setDualstackLabel(rt gatewayRoute, endpoints []*endpoint.Endpoint) {
-	val, ok := rt.Metadata().Annotations[gatewayAPIDualstackAnnotationKey]
-	if ok && val == gatewayAPIDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to GatewayRoute %s/%s.", rt.Metadata().Namespace, rt.Metadata().Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
diff --git a/source/ingress.go b/source/ingress.go
index 14676e8c..df0a39c9 100644
--- a/source/ingress.go
+++ b/source/ingress.go
@@ -37,11 +37,6 @@ import (
 )
 
 const (
-	// ALBDualstackAnnotationKey is the annotation used for determining if an ALB ingress is dualstack
-	ALBDualstackAnnotationKey = "alb.ingress.kubernetes.io/ip-address-type"
-	// ALBDualstackAnnotationValue is the value of the ALB dualstack annotation that indicates it is dualstack
-	ALBDualstackAnnotationValue = "dualstack"
-
 	// Possible values for the ingress-hostname-source annotation
 	IngressHostnameSourceAnnotationOnlyValue   = "annotation-only"
 	IngressHostnameSourceDefinedHostsOnlyValue = "defined-hosts-only"
@@ -171,7 +166,6 @@ func (sc *ingressSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e
 		}
 
 		log.Debugf("Endpoints generated from ingress: %s/%s: %v", ing.Namespace, ing.Name, ingEndpoints)
-		sc.setDualstackLabel(ing, ingEndpoints)
 		endpoints = append(endpoints, ingEndpoints...)
 	}
 
@@ -274,16 +268,6 @@ func (sc *ingressSource) filterByIngressClass(ingresses []*networkv1.Ingress) ([
 	return filteredList, nil
 }
 
-func (sc *ingressSource) setDualstackLabel(ingress *networkv1.Ingress, endpoints []*endpoint.Endpoint) {
-	val, ok := ingress.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to ingress %s/%s.", ingress.Namespace, ingress.Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-
 // endpointsFromIngress extracts the endpoints from ingress object
 func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, ignoreIngressTLSSpec bool, ignoreIngressRulesSpec bool) []*endpoint.Endpoint {
 	resource := fmt.Sprintf("ingress/%s/%s", ing.Namespace, ing.Name)
diff --git a/source/kong_tcpingress.go b/source/kong_tcpingress.go
index 0138efb3..7d81832b 100644
--- a/source/kong_tcpingress.go
+++ b/source/kong_tcpingress.go
@@ -150,7 +150,6 @@ func (sc *kongTCPIngressSource) Endpoints(ctx context.Context) ([]*endpoint.Endp
 		}
 
 		log.Debugf("Endpoints generated from TCPIngress: %s: %v", fullname, ingressEndpoints)
-		sc.setDualstackLabel(tcpIngress, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -192,16 +191,6 @@ func (sc *kongTCPIngressSource) filterByAnnotations(tcpIngresses []*TCPIngress)
 	return filteredList, nil
 }
 
-func (sc *kongTCPIngressSource) setDualstackLabel(tcpIngress *TCPIngress, endpoints []*endpoint.Endpoint) {
-	val, ok := tcpIngress.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to TCPIngress %s/%s.", tcpIngress.Namespace, tcpIngress.Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-
 // endpointsFromTCPIngress extracts the endpoints from a TCPIngress object
 func (sc *kongTCPIngressSource) endpointsFromTCPIngress(tcpIngress *TCPIngress, targets endpoint.Targets) ([]*endpoint.Endpoint, error) {
 	var endpoints []*endpoint.Endpoint
diff --git a/source/skipper_routegroup.go b/source/skipper_routegroup.go
index 3854722c..d47682ba 100644
--- a/source/skipper_routegroup.go
+++ b/source/skipper_routegroup.go
@@ -280,7 +280,6 @@ func (sc *routeGroupSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint
 		}
 
 		log.Debugf("Endpoints generated from ingress: %s/%s: %v", rg.Metadata.Namespace, rg.Metadata.Name, eps)
-		sc.setRouteGroupDualstackLabel(rg, eps)
 		endpoints = append(endpoints, eps...)
 	}
 
@@ -324,16 +323,6 @@ func (sc *routeGroupSource) endpointsFromTemplate(rg *routeGroup) ([]*endpoint.E
 	return endpoints, nil
 }
 
-func (sc *routeGroupSource) setRouteGroupDualstackLabel(rg *routeGroup, eps []*endpoint.Endpoint) {
-	val, ok := rg.Metadata.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to routegroup %s/%s.", rg.Metadata.Namespace, rg.Metadata.Name)
-		for _, ep := range eps {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-
 // annotation logic ported from source/ingress.go without Spec.TLS part, because it'S not supported in RouteGroup
 func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint.Endpoint {
 	endpoints := []*endpoint.Endpoint{}
diff --git a/source/traefik_proxy.go b/source/traefik_proxy.go
index ea4c41f5..a63142ef 100644
--- a/source/traefik_proxy.go
+++ b/source/traefik_proxy.go
@@ -269,7 +269,6 @@ func (ts *traefikSource) ingressRouteEndpoints() ([]*endpoint.Endpoint, error) {
 		}
 
 		log.Debugf("Endpoints generated from IngressRoute: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRoute(ingressRoute, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -322,7 +321,6 @@ func (ts *traefikSource) ingressRouteTCPEndpoints() ([]*endpoint.Endpoint, error
 		}
 
 		log.Debugf("Endpoints generated from IngressRouteTCP: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRouteTCP(ingressRouteTCP, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -375,7 +373,6 @@ func (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error
 		}
 
 		log.Debugf("Endpoints generated from IngressRouteUDP: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRouteUDP(ingressRouteUDP, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -428,7 +425,6 @@ func (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error
 		}
 
 		log.Debugf("Endpoints generated from IngressRoute: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRoute(ingressRoute, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -481,7 +477,6 @@ func (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, er
 		}
 
 		log.Debugf("Endpoints generated from IngressRouteTCP: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRouteTCP(ingressRouteTCP, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -534,7 +529,6 @@ func (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, er
 		}
 
 		log.Debugf("Endpoints generated from IngressRouteUDP: %s: %v", fullname, ingressEndpoints)
-		ts.setDualstackLabelIngressRouteUDP(ingressRouteUDP, ingressEndpoints)
 		endpoints = append(endpoints, ingressEndpoints...)
 	}
 
@@ -634,34 +628,6 @@ func (ts *traefikSource) filterIngressRouteUdpByAnnotations(ingressRoutes []*Ing
 	return filteredList, nil
 }
 
-func (ts *traefikSource) setDualstackLabelIngressRoute(ingressRoute *IngressRoute, endpoints []*endpoint.Endpoint) {
-	val, ok := ingressRoute.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to IngressRoute %s/%s.", ingressRoute.Namespace, ingressRoute.Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-func (ts *traefikSource) setDualstackLabelIngressRouteTCP(ingressRoute *IngressRouteTCP, endpoints []*endpoint.Endpoint) {
-	val, ok := ingressRoute.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to IngressRouteTCP %s/%s.", ingressRoute.Namespace, ingressRoute.Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-func (ts *traefikSource) setDualstackLabelIngressRouteUDP(ingressRoute *IngressRouteUDP, endpoints []*endpoint.Endpoint) {
-	val, ok := ingressRoute.Annotations[ALBDualstackAnnotationKey]
-	if ok && val == ALBDualstackAnnotationValue {
-		log.Debugf("Adding dualstack label to IngressRouteUDP %s/%s.", ingressRoute.Namespace, ingressRoute.Name)
-		for _, ep := range endpoints {
-			ep.Labels[endpoint.DualstackLabelKey] = "true"
-		}
-	}
-}
-
 // endpointsFromIngressRoute extracts the endpoints from a IngressRoute object
 func (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, targets endpoint.Targets) ([]*endpoint.Endpoint, error) {
 	var endpoints []*endpoint.Endpoint



Test patch:
diff --git a/charts/external-dns/tests/common-metadata_test.yaml b/charts/external-dns/tests/common-metadata_test.yaml
new file mode 100644
index 00000000..3a976b42
--- /dev/null
+++ b/charts/external-dns/tests/common-metadata_test.yaml
@@ -0,0 +1,29 @@
+suite: All resources contains metadata
+templates:
+  - "*.yaml"
+release:
+  name: external-dns
+chart:
+  version: "1.15.0"
+  appVersion: "0.15.0"
+tests:
+  - it: "should contain labels metadata in each template"
+    set:
+      secretConfiguration:
+        enabled: true
+        mountPath: "/etc/kubernetes/"
+      serviceMonitor:
+        enabled: true
+    asserts:
+      - exists:
+          path: metadata.labels
+      - isNotEmpty:
+          path: metadata.labels
+      - equal:
+          path: metadata.labels
+          value:
+            helm.sh/chart: external-dns-1.15.0
+            app.kubernetes.io/name: external-dns
+            app.kubernetes.io/instance: external-dns
+            app.kubernetes.io/version: "0.15.0"
+            app.kubernetes.io/managed-by: Helm
diff --git a/charts/external-dns/tests/deployment-config_test.yaml b/charts/external-dns/tests/deployment-config_test.yaml
new file mode 100644
index 00000000..4f7268ef
--- /dev/null
+++ b/charts/external-dns/tests/deployment-config_test.yaml
@@ -0,0 +1,80 @@
+suite: Deployment configuration
+templates:
+  - deployment.yaml
+release:
+  namespace: default
+tests:
+  - it: should provide expected defaults
+    asserts:
+      - isKind:
+            of: Deployment
+      - hasDocuments:
+          count: 1
+      - equal:
+          path: spec.replicas
+          value: 1
+      - equal:
+          path: spec.strategy.type
+          value: Recreate
+      - equal:
+          path: metadata.namespace
+          value: default
+      - notExists:
+          path: spec.template.spec.automountServiceAccountToken
+
+  - it: should provide expected defaults for securityContext
+    asserts:
+      - isSubset:
+          path: spec.template.spec.containers[?(@.name == "external-dns")]
+          content:
+            securityContext:
+              allowPrivilegeEscalation: false
+              capabilities:
+                drop:
+                  - ALL
+              privileged: false
+              readOnlyRootFilesystem: true
+              runAsGroup: 65532
+              runAsNonRoot: true
+              runAsUser: 65532
+
+  - it: should provide expected defaults for liveness and readiness
+    asserts:
+      - isSubset:
+          path: spec.template.spec.containers[?(@.name == "external-dns")]
+          content:
+            readinessProbe:
+              failureThreshold: 6
+              httpGet:
+                path: /healthz
+                port: http
+              initialDelaySeconds: 5
+              periodSeconds: 10
+              successThreshold: 1
+              timeoutSeconds: 5
+            livenessProbe:
+              failureThreshold: 2
+              httpGet:
+                path: /healthz
+                port: http
+              initialDelaySeconds: 10
+              periodSeconds: 10
+              successThreshold: 1
+              timeoutSeconds: 5
+
+  - it: should have service account set
+    release:
+      name: test
+    asserts:
+      - equal:
+          path: spec.template.spec.serviceAccountName
+          value: test-external-dns
+
+  - it: should have not be able to change replicas when specified via values
+    set:
+      deployment:
+        replicas: 3
+    asserts:
+      - equal:
+          path: spec.replicas
+          value: 1
diff --git a/charts/external-dns/tests/deployment-flags_test.yaml b/charts/external-dns/tests/deployment-flags_test.yaml
new file mode 100644
index 00000000..38a9567f
--- /dev/null
+++ b/charts/external-dns/tests/deployment-flags_test.yaml
@@ -0,0 +1,119 @@
+suite: Deployment flags configurations
+templates:
+  - deployment.yaml
+release:
+  namespace: default
+tests:
+  - it: should provide expected default flags
+    asserts:
+      # - matchSnapshot: {}
+      - exists :
+          path: spec.template.spec.containers[?(@.name == "external-dns")]
+      - equal :
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          value:
+            - --log-level=info
+            - --log-format=text
+            - --interval=1m
+            - --source=service
+            - --source=ingress
+            - --policy=upsert-only
+            - --registry=txt
+            - --provider=aws
+
+  - it: should configure txtPrefix and ignore txtSuffix if not empty
+    set:
+      provider:
+        name: cloudflare
+      txtPrefix: "test-prefix"
+      txtSuffix: "test-suffix"
+      txtOwnerId: "testing"
+    asserts:
+      - exists :
+          path: spec.template.spec.containers[?(@.name == "external-dns")]
+      - equal :
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          value:
+            - --log-level=info
+            - --log-format=text
+            - --interval=1m
+            - --source=service
+            - --source=ingress
+            - --policy=upsert-only
+            - --registry=txt
+            - --txt-owner-id=testing
+            - --txt-prefix=test-prefix
+            - --provider=cloudflare
+      - notContains:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          content: "--txt-suffix=test-suffix"
+
+  - it: should configure 'txtSuffix' when not empty with 'txtPrefix' empty
+    set:
+      txtPrefix: ""
+      txtSuffix: "test-suffix"
+    asserts:
+      - exists :
+          path: spec.template.spec.containers[?(@.name == "external-dns")]
+      - equal :
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          value:
+            - --log-level=info
+            - --log-format=text
+            - --interval=1m
+            - --source=service
+            - --source=ingress
+            - --policy=upsert-only
+            - --registry=txt
+            - --txt-suffix=test-suffix
+            - --provider=aws
+      - notContains:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          content: "--txt-prefix=test-prefix"
+
+  - it: should be able configure multiple sources
+    set:
+      sources:
+        - fake
+        - crd
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          content: "--source=fake"
+      - contains:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          content: "--source=crd"
+
+  - it: should be able to configure in single namespace
+    set:
+      namespaced: true
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          content: "--namespace=default"
+
+  - it: should manage multiple zones with 'extraArgs'
+    set:
+      extraArgs:
+        - --zone-id-filter=/hostedzone/Z00001
+        - --zone-id-filter=/hostedzone/Z00002
+        - --zone-id-filter=/hostedzone/Z00003
+        - --zone-id-filter=/hostedzone/Z00004
+        - --zone-id-filter=/hostedzone/Z00005
+    asserts:
+      - equal:
+          path: spec.template.spec.containers[?(@.name == "external-dns")].args
+          value:
+            - --log-level=info
+            - --log-format=text
+            - --interval=1m
+            - --source=service
+            - --source=ingress
+            - --policy=upsert-only
+            - --registry=txt
+            - --provider=aws
+            - --zone-id-filter=/hostedzone/Z00001
+            - --zone-id-filter=/hostedzone/Z00002
+            - --zone-id-filter=/hostedzone/Z00003
+            - --zone-id-filter=/hostedzone/Z00004
+            - --zone-id-filter=/hostedzone/Z00005
diff --git a/charts/external-dns/tests/deployment-scheduling_test.yaml b/charts/external-dns/tests/deployment-scheduling_test.yaml
new file mode 100644
index 00000000..849bb17a
--- /dev/null
+++ b/charts/external-dns/tests/deployment-scheduling_test.yaml
@@ -0,0 +1,118 @@
+suite: Deployment scheduling configuration
+templates:
+  - deployment.yaml
+release:
+  namespace: default
+tests:
+  - it: should not provide defaults for affinities or tolerations
+    asserts:
+      - isKind:
+          of: Deployment
+      - notExists:
+          path: spec.template.spec.tolerations
+      - notExists:
+          path: spec.template.spec.affinity
+
+  - it: should provide support for custom affinities
+    set:
+      affinity:
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            nodeSelectorTerms:
+            - matchExpressions:
+              - key: topology.kubernetes.io/zone
+                operator: In
+                values:
+                - zoneA
+                - zoneB
+    asserts:
+      - equal:
+          path: spec.template.spec.affinity
+          value:
+            nodeAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                nodeSelectorTerms:
+                - matchExpressions:
+                  - key: topology.kubernetes.io/zone
+                    operator: In
+                    values:
+                    - zoneA
+                    - zoneB
+
+  - it: should provide support for configuring node and pod affinities
+    set:
+      affinity:
+        podAntiAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            - topologyKey: kubernetes.io/hostname
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - test-pod-scheduling-config
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            nodeSelectorTerms:
+              - matchExpressions:
+                - key: datadog-agent
+                  operator: In
+                  values:
+                  - standard
+                - key: iam/scope
+                  operator: In
+                  values:
+                  - namespace
+    asserts:
+      - equal:
+          path: spec.template.spec.affinity
+          value:
+            nodeAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                nodeSelectorTerms:
+                  - matchExpressions:
+                      - key: datadog-agent
+                        operator: In
+                        values:
+                          - standard
+                      - key: iam/scope
+                        operator: In
+                        values:
+                          - namespace
+            podAntiAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+                - labelSelector:
+                    matchExpressions:
+                      - key: app
+                        operator: In
+                        values:
+                          - test-pod-scheduling-config
+                  topologyKey: kubernetes.io/hostname
+
+  - it: should provide support for configuring tolerations
+    set:
+      tolerations:
+        - key: "key1"
+          operator: "Equal"
+          value: "value1"
+          effect: "NoSchedule"
+        - key: iam/scope
+          operator: Equal
+          value: namespace
+          effect: NoExecute
+    asserts:
+      - notExists:
+          path: spec.template.spec.affinity
+      - exists:
+          path: spec.template.spec.tolerations
+      - equal:
+          path: spec.template.spec.tolerations
+          value:
+            - effect: NoSchedule
+              key: key1
+              operator: Equal
+              value: value1
+            - effect: NoExecute
+              key: iam/scope
+              operator: Equal
+              value: namespace
diff --git a/charts/external-dns/tests/notes_test.yaml b/charts/external-dns/tests/notes_test.yaml
new file mode 100644
index 00000000..c165ca2d
--- /dev/null
+++ b/charts/external-dns/tests/notes_test.yaml
@@ -0,0 +1,21 @@
+suite: NOTES.txt output
+templates:
+  - NOTES.txt
+chart:
+  appVersion: 0.15.0
+  version: 1.15.0
+tests:
+  - it: should display message
+    set:
+      rbac:
+        namespaced: true
+    asserts:
+      - equalRaw:
+          value: |
+            ***********************************************************************
+            * External DNS                                                        *
+            ***********************************************************************
+              Chart version: 1.15.0
+              App version:   0.15.0
+              Image tag:     registry.k8s.io/external-dns/external-dns:v0.15.0
+            ***********************************************************************
diff --git a/charts/external-dns/tests/rbac_test.yaml b/charts/external-dns/tests/rbac_test.yaml
new file mode 100644
index 00000000..fd1b8cfb
--- /dev/null
+++ b/charts/external-dns/tests/rbac_test.yaml
@@ -0,0 +1,155 @@
+suite: RBAC configuration
+templates:
+  - clusterrole.yaml
+  - clusterrolebinding.yaml
+  - serviceaccount.yaml
+release:
+  name: rbac
+tests:
+  - it: should create default RBAC related objects
+    asserts:
+      - isKind:
+          of: ClusterRole
+        template: clusterrole.yaml
+      - equal:
+          path: metadata.name
+          value: rbac-external-dns
+        template: clusterrole.yaml
+      - isKind:
+          of: ClusterRoleBinding
+        template: clusterrolebinding.yaml
+      - equal:
+          path: metadata.name
+          value: rbac-external-dns-viewer
+        template: clusterrolebinding.yaml
+      - isKind:
+          of: ServiceAccount
+        template: serviceaccount.yaml
+      - equal:
+          path: metadata.name
+          value: rbac-external-dns
+        template: serviceaccount.yaml
+
+  - it: should create default RBAC rules
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: [""]
+              resources: ["nodes"]
+              verbs: ["list", "watch"]
+            - apiGroups: [""]
+              resources: ["pods"]
+              verbs: ["get", "watch", "list"]
+            - apiGroups: [""]
+              resources: ["services","endpoints"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["extensions","networking.k8s.io"]
+              resources: ["ingresses"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'ambassador-host'
+    set:
+      sources:
+        - ambassador-host
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["getambassador.io"]
+              resources: ["hosts","ingresses"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'crd' and 'traefik-proxy'
+    set:
+      sources:
+        - crd
+        - traefik-proxy
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["externaldns.k8s.io"]
+              resources: ["dnsendpoints"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["externaldns.k8s.io"]
+              resources: ["dnsendpoints/status"]
+              verbs: ["*"]
+            - apiGroups: ["traefik.containo.us", "traefik.io"]
+              resources: ["ingressroutes", "ingressroutetcps", "ingressrouteudps"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'f5' when 'f5-virtualserver' is set
+    set:
+      sources:
+        - f5-virtualserver
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["cis.f5.com"]
+              resources: ["virtualservers", "transportservers"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'f5' when 'f5-transportserver' is set
+    set:
+      sources:
+        - f5-transportserver
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["cis.f5.com"]
+              resources: ["virtualservers", "transportservers"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'gateway-api' with source 'gateway-httproute'
+    set:
+      sources:
+        - gateway-httproute
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["gateways"]
+              verbs: ["get","watch","list"]
+            - apiGroups: [""]
+              resources: ["namespaces"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["httproutes"]
+              verbs: ["get","watch","list"]
+
+  - it: should create default RBAC rules for 'gateway-api' with sources 'tlsroute,tcproute,udproute'
+    set:
+      sources:
+        - gateway-tlsroute
+        - gateway-tcproute
+        - gateway-udproute
+    asserts:
+      - template: clusterrole.yaml
+        equal:
+          path: rules
+          value:
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["gateways"]
+              verbs: ["get","watch","list"]
+            - apiGroups: [""]
+              resources: ["namespaces"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["tlsroutes"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["tcproutes"]
+              verbs: ["get","watch","list"]
+            - apiGroups: ["gateway.networking.k8s.io"]
+              resources: ["udproutes"]
+              verbs: ["get","watch","list"]
diff --git a/charts/external-dns/tests/secret_test.yaml b/charts/external-dns/tests/secret_test.yaml
new file mode 100644
index 00000000..67d8456b
--- /dev/null
+++ b/charts/external-dns/tests/secret_test.yaml
@@ -0,0 +1,42 @@
+suite: Secret configuration
+templates:
+  - "secret.yaml"
+  - "deployment.yaml"
+tests:
+  - it: should not provide secret by default
+    template: "secret.yaml"
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: should configure Azure secret
+    release:
+      name: under-test
+    set:
+      secretConfiguration:
+        enabled: true
+        mountPath: "/etc/kubernetes/"
+        data:
+          azure.json: |
+            {
+              "tenantId": "00000000-0000-0000-0000-000000000000",
+              "subscriptionId": "00000000-0000-0000-0000-000000000000",
+              "resourceGroup": "networking",
+              "useWorkloadIdentityExtension": true
+            }
+    asserts:
+      - hasDocuments:
+          count: 1
+      - exists:
+          path: data["azure.json"]
+        template: "secret.yaml"
+      - exists:
+          path: spec.template.spec.volumes
+        template: "deployment.yaml"
+      - equal:
+          path: spec.template.spec.volumes
+          value:
+            - name: secrets
+              secret:
+                secretName: under-test-external-dns
+        template: "deployment.yaml"
diff --git a/charts/external-dns/tests/serviceaccount_test.yaml b/charts/external-dns/tests/serviceaccount_test.yaml
new file mode 100644
index 00000000..e86ae1d1
--- /dev/null
+++ b/charts/external-dns/tests/serviceaccount_test.yaml
@@ -0,0 +1,61 @@
+suite: ServiceAccount configuration
+templates:
+  - serviceaccount.yaml
+release:
+  namespace: default
+tests:
+  - it: should provide a single service account by default
+    asserts:
+      - isKind:
+            of: ServiceAccount
+      - hasDocuments:
+          count: 1
+      - equal:
+          path: automountServiceAccountToken
+          value: null
+
+  - it: should provide a way to disable service account
+    set:
+      serviceAccount:
+        create: false
+    asserts:
+      - hasDocuments:
+          count: 0
+
+  - it: should support annotations without template variables
+    set:
+      serviceAccount:
+        annotations:
+          eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/external-dns-role
+          eks.amazonaws.com/sts-regional-endpoints: "true"
+    asserts:
+      - hasDocuments:
+          count: 1
+      - equal:
+          path: metadata.annotations
+          value:
+            eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/external-dns-role
+            eks.amazonaws.com/sts-regional-endpoints: "true"
+
+  - it: should support annotations with template variables
+    release:
+      name: v1
+    set:
+      account: 9876-dynamic-account-id
+      serviceAccount:
+        annotations:
+          eks.amazonaws.com/role-arn: arn:aws:iam::{{ .Values.account }}:role/external-dns-role
+          eks.amazonaws.com/sts-regional-endpoints: "true"
+          tags.k8s.io/service: service-{{ include "external-dns.fullname" . }}
+    asserts:
+      - hasDocuments:
+          count: 1
+      - equal:
+          path: metadata.annotations
+          value:
+            eks.amazonaws.com/role-arn: arn:aws:iam::9876-dynamic-account-id:role/external-dns-role
+            eks.amazonaws.com/sts-regional-endpoints: "true"
+            tags.k8s.io/service: service-v1-external-dns
+      - isType:
+          path: metadata.annotations["eks.amazonaws.com/sts-regional-endpoints"]
+          type: string
diff --git a/controller/controller_test.go b/controller/controller_test.go
index c6074f83..a5fca2c5 100644
--- a/controller/controller_test.go
+++ b/controller/controller_test.go
@@ -234,8 +234,8 @@ func TestRunOnce(t *testing.T) {
 	// Validate that the mock source was called.
 	source.AssertExpectations(t)
 	// check the verified records
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords))
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords.Gauge))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords.Gauge))
 }
 
 // TestRun tests that Run correctly starts and stops
@@ -268,8 +268,8 @@ func TestRun(t *testing.T) {
 	// Validate that the mock source was called.
 	source.AssertExpectations(t)
 	// check the verified records
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords))
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedARecords.Gauge))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(verifiedAAAARecords.Gauge))
 }
 
 func valueFromMetric(metric prometheus.Gauge) uint64 {
@@ -520,7 +520,7 @@ func TestVerifyARecords(t *testing.T) {
 		},
 		[]*plan.Changes{},
 	)
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords.Gauge))
 
 	testControllerFiltersDomains(
 		t,
@@ -564,8 +564,8 @@ func TestVerifyARecords(t *testing.T) {
 			},
 		}},
 	)
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords))
-	assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedAAAARecords))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedARecords.Gauge))
+	assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedAAAARecords.Gauge))
 }
 
 func TestVerifyAAAARecords(t *testing.T) {
@@ -598,7 +598,7 @@ func TestVerifyAAAARecords(t *testing.T) {
 		},
 		[]*plan.Changes{},
 	)
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords.Gauge))
 
 	testControllerFiltersDomains(
 		t,
@@ -642,8 +642,8 @@ func TestVerifyAAAARecords(t *testing.T) {
 			},
 		}},
 	)
-	assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedARecords))
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords))
+	assert.Equal(t, math.Float64bits(0), valueFromMetric(verifiedARecords.Gauge))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(verifiedAAAARecords.Gauge))
 }
 
 func TestARecords(t *testing.T) {
@@ -689,8 +689,8 @@ func TestARecords(t *testing.T) {
 			},
 		}},
 	)
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceARecords))
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(registryARecords))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceARecords.Gauge))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(registryARecords.Gauge))
 }
 
 func TestAAAARecords(t *testing.T) {
@@ -736,6 +736,6 @@ func TestAAAARecords(t *testing.T) {
 			},
 		}},
 	)
-	assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceAAAARecords))
-	assert.Equal(t, math.Float64bits(1), valueFromMetric(registryAAAARecords))
+	assert.Equal(t, math.Float64bits(2), valueFromMetric(sourceAAAARecords.Gauge))
+	assert.Equal(t, math.Float64bits(1), valueFromMetric(registryAAAARecords.Gauge))
 }
diff --git a/internal/gen/docs/flags/main_test.go b/internal/gen/docs/flags/main_test.go
index 60fa2c11..83b1ca3f 100644
--- a/internal/gen/docs/flags/main_test.go
+++ b/internal/gen/docs/flags/main_test.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2017 The Kubernetes Authors.
+Copyright 2025 The Kubernetes Authors.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
diff --git a/internal/gen/docs/metrics/main_test.go b/internal/gen/docs/metrics/main_test.go
new file mode 100644
index 00000000..260ffad9
--- /dev/null
+++ b/internal/gen/docs/metrics/main_test.go
@@ -0,0 +1,133 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"fmt"
+	"io/fs"
+	"math/rand/v2"
+	"os"
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/external-dns/pkg/metrics"
+)
+
+const pathToDocs = "%s/../../../../docs/monitoring"
+
+func TestComputeMetrics(t *testing.T) {
+	reg := metrics.RegisterMetric
+
+	if len(reg.Metrics) == 0 {
+		t.Errorf("Expected not empty metrics registry, got %d", len(reg.Metrics))
+	}
+
+	assert.Len(t, reg.Metrics, 21)
+}
+
+func TestGenerateMarkdownTableRenderer(t *testing.T) {
+	reg := metrics.NewMetricsRegister()
+
+	got, err := generateMarkdownTable(reg, false)
+	assert.NoError(t, err)
+
+	assert.Contains(t, got, "# Available Metrics\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n")
+	assert.Contains(t, got, "| Metric Type | Subsystem   |  Help")
+}
+
+func TestGenerateMarkdownTableWithSingleMetric(t *testing.T) {
+	reg := metrics.NewMetricsRegister()
+
+	reg.MustRegister(metrics.NewGaugeWithOpts(
+		prometheus.GaugeOpts{
+			Namespace: "external_dns",
+			Subsystem: fmt.Sprintf("controller_%d", rand.IntN(100)),
+			Name:      "verified_aaaa_records",
+			Help:      "This is just a test.",
+		},
+	))
+
+	got, err := generateMarkdownTable(reg, false)
+	assert.NoError(t, err)
+
+	assert.Contains(t, got, "verified_aaaa_records")
+	assert.Contains(t, got, "This is just a test.")
+}
+
+func TestMetricsMdUpToDate(t *testing.T) {
+	testPath, _ := os.Getwd()
+	fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))
+	fileName := "metrics.md"
+	expected, err := fs.ReadFile(fsys, fileName)
+	assert.NoError(t, err, "expected file %s to exist", fileName)
+
+	reg := metrics.RegisterMetric
+	actual, err := generateMarkdownTable(reg, false)
+	assert.NoError(t, err)
+	assert.Contains(t, string(expected), actual)
+}
+
+func TestMetricsMdExtraMetricAdded(t *testing.T) {
+	testPath, _ := os.Getwd()
+	fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))
+	fileName := "metrics.md"
+	expected, err := fs.ReadFile(fsys, fileName)
+	assert.NoError(t, err, "expected file %s to exist", fileName)
+
+	reg := metrics.RegisterMetric
+
+	reg.MustRegister(metrics.NewGaugeWithOpts(
+		prometheus.GaugeOpts{
+			Namespace: "external_dns",
+			Subsystem: fmt.Sprintf("controller_%d", rand.IntN(100)),
+			Name:      "verified_aaaa_records",
+			Help:      "This is just a test.",
+		},
+	))
+
+	actual, err := generateMarkdownTable(reg, false)
+	assert.NoError(t, err)
+	assert.NotEqual(t, string(expected), actual)
+}
+
+func TestGetRuntimeMetricsForNewRegistry(t *testing.T) {
+	reg := prometheus.NewRegistry()
+	// Register some runtime metrics
+	reg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{
+		Name: "go_goroutines",
+		Help: "Number of goroutines that currently exist.",
+	}))
+	reg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{
+		Name: "go_memstats_alloc_bytes",
+		Help: "Number of bytes allocated and still in use.",
+	}))
+	runtimeMetrics := getRuntimeMetrics(reg)
+
+	// Check that the runtime metrics are correctly retrieved
+	expectedMetrics := []string{"go_goroutines", "go_memstats_alloc_bytes"}
+	assert.ElementsMatch(t, expectedMetrics, runtimeMetrics)
+	assert.Len(t, runtimeMetrics, 2)
+}
+
+func TestGetRuntimeMetricsForDefaultRegistry(t *testing.T) {
+	reg := prometheus.DefaultRegisterer
+	runtimeMetrics := getRuntimeMetrics(reg)
+	if len(runtimeMetrics) == 0 {
+		t.Errorf("Expected not empty runtime metrics, got %d", len(runtimeMetrics))
+	}
+}
diff --git a/internal/gen/docs/utils/utils_test.go b/internal/gen/docs/utils/utils_test.go
new file mode 100644
index 00000000..b330e9f9
--- /dev/null
+++ b/internal/gen/docs/utils/utils_test.go
@@ -0,0 +1,73 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package utils
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+	"text/template"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWriteToFile(t *testing.T) {
+	filename := fmt.Sprintf("%s/testfile", t.TempDir())
+	content := "Hello, World!"
+
+	defer os.Remove(filename)
+
+	err := WriteToFile(filename, content)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		t.Fatalf("expected no error reading file, got %v", err)
+	}
+
+	if string(data) != content {
+		t.Errorf("expected content %q, got %q", content, string(data))
+	}
+}
+
+func TestFuncs(t *testing.T) {
+	tests := []struct {
+		tpl, expect string
+		vars        interface{}
+	}{
+		{
+			tpl:    `{{ backtick 3 }}`,
+			expect: "```",
+			vars:   map[string]interface{}{},
+		},
+		{
+			tpl:    `{{ capitalize .name }}`,
+			expect: "Capital",
+			vars:   map[string]interface{}{"name": "capital"},
+		},
+	}
+
+	for _, tt := range tests {
+		var b strings.Builder
+		err := template.Must(template.New("test").Funcs(FuncMap()).Parse(tt.tpl)).Execute(&b, tt.vars)
+		assert.NoError(t, err)
+		assert.Equal(t, tt.expect, b.String(), tt.tpl)
+	}
+}
diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go
new file mode 100644
index 00000000..878ed784
--- /dev/null
+++ b/pkg/metrics/metrics_test.go
@@ -0,0 +1,103 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package metrics
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/external-dns/internal/testutils"
+)
+
+type MockMetric struct {
+	FQDN string
+}
+
+func (m *MockMetric) Get() *Metric {
+	return &Metric{FQDN: m.FQDN}
+}
+
+func TestMustRegister(t *testing.T) {
+	tests := []struct {
+		name     string
+		metrics  []IMetric
+		expected int
+	}{
+		{
+			name: "single metric",
+			metrics: []IMetric{
+				NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_1"}),
+			},
+			expected: 1,
+		},
+		{
+			name: "two metrics",
+			metrics: []IMetric{
+				NewGaugeWithOpts(prometheus.GaugeOpts{Name: "test_gauge_2", Subsystem: "test"}),
+				NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_2", Subsystem: "app"}),
+			},
+			expected: 2,
+		},
+		{
+			name: "mix of metrics",
+			metrics: []IMetric{
+				NewGaugeWithOpts(prometheus.GaugeOpts{Name: "test_gauge_3"}),
+				NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_3"}),
+				NewCounterVecWithOpts(prometheus.CounterOpts{Name: "test_counter_vec_3"}, []string{"label"}),
+			},
+			expected: 3,
+		},
+		{
+			name: "unsupported metric",
+			metrics: []IMetric{
+				&MockMetric{FQDN: "unsupported_metric"},
+			},
+			expected: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			registry := NewMetricsRegister()
+			for _, m := range tt.metrics {
+				registry.MustRegister(m)
+			}
+			assert.Len(t, registry.Metrics, tt.expected)
+		})
+	}
+}
+
+func TestUnsupportedMetricWarning(t *testing.T) {
+	buf := testutils.LogsToBuffer(log.WarnLevel, t)
+	registry := NewMetricsRegister()
+	mockUnsupported := &MockMetric{FQDN: "unsupported_metric"}
+	registry.MustRegister(mockUnsupported)
+	assert.NotContains(t, registry.mName, "unsupported_metric")
+
+	assert.Contains(t, buf.String(), "Unsupported metric type: *metrics.MockMetric")
+}
+
+func TestNewMetricsRegister(t *testing.T) {
+	registry := NewMetricsRegister()
+
+	assert.NotNil(t, registry)
+	assert.NotNil(t, registry.Registerer)
+	assert.Empty(t, registry.Metrics)
+	assert.Empty(t, registry.mName)
+}
diff --git a/pkg/metrics/models_test.go b/pkg/metrics/models_test.go
new file mode 100644
index 00000000..aad967c4
--- /dev/null
+++ b/pkg/metrics/models_test.go
@@ -0,0 +1,83 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package metrics
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewGaugeWithOpts(t *testing.T) {
+	opts := prometheus.GaugeOpts{
+		Name:      "test_gauge",
+		Namespace: "test_namespace",
+		Subsystem: "test_subsystem",
+		Help:      "This is a test gauge",
+	}
+
+	gaugeMetric := NewGaugeWithOpts(opts)
+
+	assert.Equal(t, "gauge", gaugeMetric.Type)
+	assert.Equal(t, "test_gauge", gaugeMetric.Name)
+	assert.Equal(t, "test_namespace", gaugeMetric.Namespace)
+	assert.Equal(t, "test_subsystem", gaugeMetric.Subsystem)
+	assert.Equal(t, "This is a test gauge", gaugeMetric.Help)
+	assert.Equal(t, "test_subsystem_test_gauge", gaugeMetric.FQDN)
+	assert.NotNil(t, gaugeMetric.Gauge)
+}
+
+func TestNewCounterWithOpts(t *testing.T) {
+	opts := prometheus.CounterOpts{
+		Name:      "test_counter",
+		Namespace: "test_namespace",
+		Subsystem: "test_subsystem",
+		Help:      "This is a test counter",
+	}
+
+	counterMetric := NewCounterWithOpts(opts)
+
+	assert.Equal(t, "counter", counterMetric.Type)
+	assert.Equal(t, "test_counter", counterMetric.Name)
+	assert.Equal(t, "test_namespace", counterMetric.Namespace)
+	assert.Equal(t, "test_subsystem", counterMetric.Subsystem)
+	assert.Equal(t, "This is a test counter", counterMetric.Help)
+	assert.Equal(t, "test_subsystem_test_counter", counterMetric.FQDN)
+	assert.NotNil(t, counterMetric.Counter)
+}
+
+func TestNewCounterVecWithOpts(t *testing.T) {
+	opts := prometheus.CounterOpts{
+		Name:      "test_counter_vec",
+		Namespace: "test_namespace",
+		Subsystem: "test_subsystem",
+		Help:      "This is a test counter vector",
+	}
+
+	labelNames := []string{"label1", "label2"}
+
+	counterVecMetric := NewCounterVecWithOpts(opts, labelNames)
+
+	assert.Equal(t, "counter", counterVecMetric.Type)
+	assert.Equal(t, "test_counter_vec", counterVecMetric.Name)
+	assert.Equal(t, "test_namespace", counterVecMetric.Namespace)
+	assert.Equal(t, "test_subsystem", counterVecMetric.Subsystem)
+	assert.Equal(t, "This is a test counter vector", counterVecMetric.Help)
+	assert.Equal(t, "test_subsystem_test_counter_vec", counterVecMetric.FQDN)
+	assert.NotNil(t, counterVecMetric.CounterVec)
+}
diff --git a/provider/aws/aws_fixtures_test.go b/provider/aws/aws_fixtures_test.go
index 21c65e33..0edeb0d7 100644
--- a/provider/aws/aws_fixtures_test.go
+++ b/provider/aws/aws_fixtures_test.go
@@ -18,6 +18,8 @@ package aws
 
 import (
 	"context"
+	"fmt"
+	"strings"
 	"testing"
 
 	log "github.com/sirupsen/logrus"
@@ -57,7 +59,36 @@ func TestAWSZonesFilterWithTags(t *testing.T) {
 	z, err := provider.Zones(ctx)
 	assert.NoError(t, err)
 	assert.EqualValues(t, 24, len(z))
-	assert.Equal(t, 169, stub.calls["listtagsforresource"])
+	assert.Equal(t, 17, stub.calls["listtagsforresource"])
+}
+
+func TestAWSZonesFiltersWithTags(t *testing.T) {
+	tests := []struct {
+		filters     []string
+		want, calls int
+	}{
+		{[]string{"owner=ext-dns"}, 169, 17},
+		{[]string{"domain=n3.n2.n1.ex.com"}, 1, 17},
+		{[]string{"parentdomain=n3.n2.n1.ex.com"}, 1, 17},
+		{[]string{"vpcid=vpc-not-exists"}, 0, 17},
+	}
+
+	for _, tt := range tests {
+		tName := fmt.Sprintf("filters=%s and zones=%d", strings.Join(tt.filters, ","), tt.want)
+		t.Run(tName, func(t *testing.T) {
+			var zones HostedZones
+			unmarshalTestHelper("/fixtures/160-plus-zones.yaml", &zones, t)
+
+			stub := NewRoute53APIFixtureStub(&zones)
+			provider := providerFilters(stub,
+				WithZoneTagFilters(tt.filters),
+			)
+			z, err := provider.Zones(context.Background())
+			assert.NoError(t, err)
+			assert.EqualValues(t, tt.want, len(z))
+			assert.EqualValues(t, tt.calls, stub.calls["listtagsforresource"])
+		})
+	}
 }
 
 func TestAWSZonesSecondRequestHitsTheCache(t *testing.T) {
diff --git a/provider/aws/aws_test.go b/provider/aws/aws_test.go
index 75e29103..7305f064 100644
--- a/provider/aws/aws_test.go
+++ b/provider/aws/aws_test.go
@@ -134,9 +134,9 @@ func (c *Route53APICounter) ListHostedZones(ctx context.Context, input *route53.
 	return c.wrapped.ListHostedZones(ctx, input, optFns...)
 }
 
-func (c *Route53APICounter) ListTagsForResource(ctx context.Context, input *route53.ListTagsForResourceInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourceOutput, error) {
+func (c *Route53APICounter) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
 	c.calls["ListTagsForResource"]++
-	return c.wrapped.ListTagsForResource(ctx, input, optFns...)
+	return c.wrapped.ListTagsForResources(ctx, input, optFns...)
 }
 
 // Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk
@@ -161,22 +161,26 @@ func specialCharactersEscape(s string) string {
 	return result.String()
 }
 
-func (r *Route53APIStub) ListTagsForResource(ctx context.Context, input *route53.ListTagsForResourceInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourceOutput, error) {
+func (r *Route53APIStub) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
 	if input.ResourceType == route53types.TagResourceTypeHostedzone {
-		zoneId := fmt.Sprintf("/%s/%s", input.ResourceType, *input.ResourceId)
-		if strings.Contains(zoneId, "ext-dns-test-error-on-list-tags") {
-			return nil, fmt.Errorf("operation error Route53APIStub: ListTagsForResource")
+		var sets []route53types.ResourceTagSet
+		for _, el := range input.ResourceIds {
+			zoneId := fmt.Sprintf("/%s/%s", input.ResourceType, el)
+			if strings.Contains(zoneId, "ext-dns-test-error-on-list-tags") {
+				return nil, fmt.Errorf("operation error Route53APIStub: ListTagsForResource")
+			}
+
+			if r.zoneTags[zoneId] != nil {
+				sets = append(sets, route53types.ResourceTagSet{
+					ResourceId:   &el,
+					ResourceType: route53types.TagResourceTypeHostedzone,
+					Tags:         r.zoneTags[zoneId],
+				})
+			}
 		}
-		tags := r.zoneTags[zoneId]
-		return &route53.ListTagsForResourceOutput{
-			ResourceTagSet: &route53types.ResourceTagSet{
-				ResourceId:   input.ResourceId,
-				ResourceType: input.ResourceType,
-				Tags:         tags,
-			},
-		}, nil
+		return &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil
 	}
-	return &route53.ListTagsForResourceOutput{}, nil
+	return &route53.ListTagsForResourcesOutput{}, nil
 }
 
 func (r *Route53APIStub) ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
@@ -333,7 +337,8 @@ func TestAWSZones(t *testing.T) {
 		{"private filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("private"), provider.NewZoneTagFilter([]string{}), privateZones},
 		{"unknown filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("unknown"), provider.NewZoneTagFilter([]string{}), noZones},
 		{"zone id filter", provider.NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), privateZones},
-		{"tag filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=3"}), privateZones},
+		{"tag filter zero zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=not-exists"}), noZones},
+		{"tag filter single zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=3"}), privateZones},
 	} {
 		t.Run(ti.msg, func(t *testing.T) {
 			provider, _ := newAWSProviderWithTagFilter(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, nil)
@@ -364,7 +369,7 @@ func TestAWSZonesWithTagFilterError(t *testing.T) {
 	})
 	_, err := provider.Zones(context.Background())
 	require.Error(t, err)
-	require.ErrorContains(t, err, "failed to list tags for zone /hostedzone/zone-2.ext-dns-test-error-on-list-tags")
+	require.ErrorContains(t, err, "failed to list tags for zones")
 }
 
 func TestAWSRecordsFilter(t *testing.T) {
@@ -432,6 +437,16 @@ func TestAWSRecords(t *testing.T) {
 				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 			},
 		},
+		{
+			Name: aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.")),
+			Type: route53types.RRTypeAaaa,
+			TTL:  aws.Int64(recordTTL),
+			AliasTarget: &route53types.AliasTarget{
+				DNSName:              aws.String("escape-codes.eu-central-1.elb.amazonaws.com."),
+				EvaluateTargetHealth: false,
+				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+			},
+		},
 		{
 			Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
 			Type: route53types.RRTypeA,
@@ -441,6 +456,15 @@ func TestAWSRecords(t *testing.T) {
 				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 			},
 		},
+		{
+			Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+			Type: route53types.RRTypeAaaa,
+			AliasTarget: &route53types.AliasTarget{
+				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
+				EvaluateTargetHealth: false,
+				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+			},
+		},
 		{
 			Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
 			Type: route53types.RRTypeA,
@@ -450,6 +474,15 @@ func TestAWSRecords(t *testing.T) {
 				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 			},
 		},
+		{
+			Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+			Type: route53types.RRTypeAaaa,
+			AliasTarget: &route53types.AliasTarget{
+				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
+				EvaluateTargetHealth: false,
+				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+			},
+		},
 		{
 			Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."),
 			Type: route53types.RRTypeA,
@@ -459,6 +492,15 @@ func TestAWSRecords(t *testing.T) {
 				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 			},
 		},
+		{
+			Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."),
+			Type: route53types.RRTypeAaaa,
+			AliasTarget: &route53types.AliasTarget{
+				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
+				EvaluateTargetHealth: true,
+				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+			},
+		},
 		{
 			Name:            aws.String("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do."),
 			Type:            route53types.RRTypeA,
@@ -577,9 +619,13 @@ func TestAWSRecords(t *testing.T) {
 		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "example").WithProviderSpecific(providerSpecificAlias, "false"),
 		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"),
 		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
+		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
 		endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
+		endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
 		endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
+		endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
 		endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"),
+		endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"),
 		endpoint.NewEndpointWithTTL("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"),
 		endpoint.NewEndpointWithTTL("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "random"),
 		endpoint.NewEndpointWithTTL("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10"),
@@ -619,6 +665,7 @@ func TestAWSAdjustEndpoints(t *testing.T) {
 		endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
 		endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com"),
 		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"),
+		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"),
 		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com"),
 		endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"),
 		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
@@ -632,10 +679,14 @@ func TestAWSAdjustEndpoints(t *testing.T) {
 		endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
 		endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com").WithProviderSpecific(providerSpecificAlias, "false"),
 		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
+		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
 		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
+		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
 		endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"),
-		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
+		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"),    // eth = evaluate target health
+		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
 		endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
+		endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
 	})
 }
 
@@ -662,24 +713,48 @@ func TestAWSApplyChanges(t *testing.T) {
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
 			},
+			{
+				Name:            aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
+			},
 			{
 				Name:            aws.String("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
 			},
+			{
+				Name:            aws.String("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
+			},
 			{
 				Name:            aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
 			},
+			{
+				Name:            aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name:            aws.String("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
 			},
+			{
+				Name:            aws.String("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name:            aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
@@ -695,12 +770,39 @@ func TestAWSApplyChanges(t *testing.T) {
 					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 				},
 			},
+			{
+				Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeAaaa,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+				},
+			},
 			{
 				Name:            aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeCname,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}},
 			},
+			{
+				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeA,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("bar.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+				},
+			},
+			{
+				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeAaaa,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("bar.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+				},
+			},
 			{
 				Name:            aws.String("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeCname,
@@ -708,16 +810,22 @@ func TestAWSApplyChanges(t *testing.T) {
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}},
 			},
 			{
-				Name:            aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
-				Type:            route53types.RRTypeCname,
-				TTL:             aws.Int64(recordTTL),
-				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}},
+				Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeA,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("qux.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+				},
 			},
 			{
-				Name:            aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
-				Type:            route53types.RRTypeCname,
-				TTL:             aws.Int64(recordTTL),
-				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}},
+				Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeAaaa,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("qux.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
+				},
 			},
 			{
 				Name:            aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
@@ -731,6 +839,12 @@ func TestAWSApplyChanges(t *testing.T) {
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}},
 			},
+			{
+				Name:            aws.String("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name:            aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
@@ -794,20 +908,28 @@ func TestAWSApplyChanges(t *testing.T) {
 		createRecords := []*endpoint.Endpoint{
 			endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
 			endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
+			endpoint.NewEndpoint("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
+			endpoint.NewEndpoint("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
 			endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
 			endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
+			endpoint.NewEndpoint("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost1.foo.elb.amazonaws.com"),
 		}
 
 		currentRecords := []*endpoint.Endpoint{
 			endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
 			endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
+			endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
+			endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.1.1.1"),
 			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
+			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
 			endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"),
-			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"),
+			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
+			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
 			endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
+			endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("weighted-to-simple").WithProviderSpecific(providerSpecificWeight, "10"),
 			endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
 			endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificWeight, "10"),
@@ -819,11 +941,16 @@ func TestAWSApplyChanges(t *testing.T) {
 		updatedRecords := []*endpoint.Endpoint{
 			endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
 			endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"),
+			endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
+			endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
 			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
+			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
 			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "my-internal-host.example.com"),
 			endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"),
-			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"),
+			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
+			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
 			endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
+			endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001", "2606:4700:4700::1111"),
 			endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
 			endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("simple-to-weighted").WithProviderSpecific(providerSpecificWeight, "10"),
 			endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificRegion, "us-east-1"),
@@ -835,9 +962,13 @@ func TestAWSApplyChanges(t *testing.T) {
 		deleteRecords := []*endpoint.Endpoint{
 			endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
 			endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
+			endpoint.NewEndpoint("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
+			endpoint.NewEndpoint("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"),
-			endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"),
+			endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
+			endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
 			endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
+			endpoint.NewEndpoint("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
 			endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mailhost1.foo.elb.amazonaws.com"),
 		}
 
@@ -865,12 +996,24 @@ func TestAWSApplyChanges(t *testing.T) {
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
 			},
+			{
+				Name:            aws.String("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
+			},
 			{
 				Name:            aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
 			},
+			{
+				Name:            aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type: route53types.RRTypeA,
@@ -880,6 +1023,15 @@ func TestAWSApplyChanges(t *testing.T) {
 					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
 				},
 			},
+			{
+				Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeAaaa,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("foo.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
+				},
+			},
 			{
 				Name:            aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeCname,
@@ -905,10 +1057,22 @@ func TestAWSApplyChanges(t *testing.T) {
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.elb.amazonaws.com")}},
 			},
 			{
-				Name:            aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
-				Type:            route53types.RRTypeCname,
-				TTL:             aws.Int64(recordTTL),
-				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("baz.elb.amazonaws.com")}},
+				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeA,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("baz.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
+				},
+			},
+			{
+				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Type: route53types.RRTypeAaaa,
+				AliasTarget: &route53types.AliasTarget{
+					DNSName:              aws.String("baz.elb.amazonaws.com."),
+					EvaluateTargetHealth: true,
+					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
+				},
 			},
 			{
 				Name:            aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."),
@@ -970,24 +1134,48 @@ func TestAWSApplyChanges(t *testing.T) {
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
 			},
+			{
+				Name:            aws.String("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name:            aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}},
 			},
+			{
+				Name:            aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
+			},
 			{
 				Name:            aws.String("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}},
 			},
+			{
+				Name:            aws.String("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}},
+			},
 			{
 				Name:            aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeA,
 				TTL:             aws.Int64(recordTTL),
 				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}},
 			},
+			{
+				Name:            aws.String("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
+				Type:            route53types.RRTypeAaaa,
+				TTL:             aws.Int64(recordTTL),
+				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}, {Value: aws.String("2606:4700:4700::1111")}},
+			},
 			{
 				Name:            aws.String("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."),
 				Type:            route53types.RRTypeMx,
@@ -1780,8 +1968,6 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) {
 		"":      false,
 	} {
 		provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, nil)
-
-		// Test dualstack and ipv4 load balancer targets
 		records := []*endpoint.Endpoint{
 			{
 				DNSName:    "create-test.zone-1.ext-dns-test-2.teapot.zalan.do",
@@ -1799,9 +1985,9 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) {
 				},
 			},
 			{
-				DNSName:    "create-test-dualstack.zone-1.ext-dns-test-2.teapot.zalan.do",
-				Targets:    endpoint.Targets{"bar.eu-central-1.elb.amazonaws.com"},
-				RecordType: endpoint.RecordTypeA,
+				DNSName:    "create-test.zone-1.ext-dns-test-2.teapot.zalan.do",
+				Targets:    endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"},
+				RecordType: endpoint.RecordTypeAAAA,
 				ProviderSpecific: endpoint.ProviderSpecific{
 					endpoint.ProviderSpecificProperty{
 						Name:  providerSpecificAlias,
@@ -1812,7 +1998,6 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) {
 						Value: key,
 					},
 				},
-				Labels: map[string]string{endpoint.DualstackLabelKey: "true"},
 			},
 		}
 		adjusted, err := provider.AdjustEndpoints(records)
@@ -1835,20 +2020,11 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) {
 			},
 			{
 				AliasTarget: &route53types.AliasTarget{
-					DNSName:              aws.String("bar.eu-central-1.elb.amazonaws.com."),
-					EvaluateTargetHealth: evaluateTargetHealth,
-					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
-				},
-				Name: aws.String("create-test-dualstack.zone-1.ext-dns-test-2.teapot.zalan.do."),
-				Type: route53types.RRTypeA,
-			},
-			{
-				AliasTarget: &route53types.AliasTarget{
-					DNSName:              aws.String("bar.eu-central-1.elb.amazonaws.com."),
+					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
 					EvaluateTargetHealth: evaluateTargetHealth,
 					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
 				},
-				Name: aws.String("create-test-dualstack.zone-1.ext-dns-test-2.teapot.zalan.do."),
+				Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
 				Type: route53types.RRTypeAaaa,
 			},
 		})
@@ -1882,10 +2058,14 @@ func TestAWSisAWSAlias(t *testing.T) {
 		alias      bool
 		hz         string
 	}{
-		{"foo.example.org", endpoint.RecordTypeA, false, ""},                                 // normal A record
-		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeA, true, "Z215JYRZR1TBD5"}, // pointing to ELB DNS name
-		{"foobar.example.org", endpoint.RecordTypeA, true, "Z1234567890ABC"},                 // HZID retrieved by Route53
-		{"baz.example.org", endpoint.RecordTypeA, true, sameZoneAlias},                       // record to be created
+		{"foo.example.org", endpoint.RecordTypeA, false, ""},                                    // normal A record
+		{"foo.example.org", endpoint.RecordTypeAAAA, false, ""},                                 // normal AAAA record
+		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeA, true, "Z215JYRZR1TBD5"},    // pointing to ELB DNS name (alias A)
+		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeAAAA, true, "Z215JYRZR1TBD5"}, // pointing to ELB DNS name (alias AAAA)
+		{"foobar.example.org", endpoint.RecordTypeA, true, "Z1234567890ABC"},                    // HZID retrieved by Route53 (alias A)
+		{"foobar.example.org", endpoint.RecordTypeAAAA, true, "Z1234567890ABC"},                 // HZID retrieved by Route53 (alias AAAA)
+		{"baz.example.org", endpoint.RecordTypeA, true, sameZoneAlias},                          // record to be created (alias A)
+		{"baz.example.org", endpoint.RecordTypeAAAA, true, sameZoneAlias},                       // record to be created (alias AAAA)
 	} {
 		ep := &endpoint.Endpoint{
 			Targets:    endpoint.Targets{tc.target},
diff --git a/provider/aws/aws_utils_test.go b/provider/aws/aws_utils_test.go
index 23c0f02e..a7c7e2aa 100644
--- a/provider/aws/aws_utils_test.go
+++ b/provider/aws/aws_utils_test.go
@@ -124,16 +124,21 @@ func (r Route53APIFixtureStub) ListHostedZones(ctx context.Context, input *route
 	return output, nil
 }
 
-func (r Route53APIFixtureStub) ListTagsForResource(ctx context.Context, input *route53.ListTagsForResourceInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourceOutput, error) {
+func (r Route53APIFixtureStub) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
 	r.calls["listtagsforresource"]++
-	tags := r.zoneTags[*input.ResourceId]
-	return &route53.ListTagsForResourceOutput{
-		ResourceTagSet: &route53types.ResourceTagSet{
-			ResourceId:   input.ResourceId,
-			ResourceType: input.ResourceType,
-			Tags:         tags,
-		},
-	}, nil
+
+	var sets []route53types.ResourceTagSet
+
+	for _, el := range input.ResourceIds {
+		if r.zoneTags[el] != nil {
+			sets = append(sets, route53types.ResourceTagSet{
+				ResourceId:   &el,
+				ResourceType: route53types.TagResourceTypeHostedzone,
+				Tags:         r.zoneTags[el],
+			})
+		}
+	}
+	return &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil
 }
 
 func unmarshalTestHelper(input string, obj any, t *testing.T) {
diff --git a/provider/awssd/aws_sd_test.go b/provider/awssd/aws_sd_test.go
index 38cc0711..dd851c45 100644
--- a/provider/awssd/aws_sd_test.go
+++ b/provider/awssd/aws_sd_test.go
@@ -52,6 +52,9 @@ type AWSSDClientStub struct {
 
 	// map[service_id] => map[inst_id]instance
 	instances map[string]map[string]*sdtypes.Instance
+
+	// []inst_id
+	deregistered []string
 }
 
 func (s *AWSSDClientStub) CreateService(ctx context.Context, input *sd.CreateServiceInput, optFns ...func(*sd.Options)) (*sd.CreateServiceOutput, error) {
@@ -79,6 +82,7 @@ func (s *AWSSDClientStub) CreateService(ctx context.Context, input *sd.CreateSer
 func (s *AWSSDClientStub) DeregisterInstance(ctx context.Context, input *sd.DeregisterInstanceInput, optFns ...func(options *sd.Options)) (*sd.DeregisterInstanceOutput, error) {
 	serviceInstances := s.instances[*input.ServiceId]
 	delete(serviceInstances, *input.InstanceId)
+	s.deregistered = append(s.deregistered, *input.InstanceId)
 
 	return &sd.DeregisterInstanceOutput{}, nil
 }
@@ -436,6 +440,60 @@ func TestAWSSDProvider_ApplyChanges(t *testing.T) {
 	assert.Empty(t, endpoints)
 }
 
+func TestAWSSDProvider_ApplyChanges_Update(t *testing.T) {
+	namespaces := map[string]*sdtypes.Namespace{
+		"private": {
+			Id:   aws.String("private"),
+			Name: aws.String("private.com"),
+			Type: sdtypes.NamespaceTypeDnsPrivate,
+		},
+	}
+
+	api := &AWSSDClientStub{
+		namespaces: namespaces,
+		services:   make(map[string]map[string]*sdtypes.Service),
+		instances:  make(map[string]map[string]*sdtypes.Instance),
+	}
+
+	oldEndpoints := []*endpoint.Endpoint{
+		{DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60},
+	}
+
+	newEndpoints := []*endpoint.Endpoint{
+		{DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.6"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60},
+	}
+
+	provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "")
+
+	ctx := context.Background()
+
+	// apply creates
+	provider.ApplyChanges(ctx, &plan.Changes{
+		Create: oldEndpoints,
+	})
+
+	ctx = context.Background()
+
+	// apply update
+	provider.ApplyChanges(ctx, &plan.Changes{
+		UpdateOld: oldEndpoints,
+		UpdateNew: newEndpoints,
+	})
+
+	// make sure services were created
+	assert.Len(t, api.services["private"], 1)
+	existingServices, _ := provider.ListServicesByNamespaceID(ctx, namespaces["private"].Id)
+	assert.NotNil(t, existingServices["service1"])
+
+	// make sure instances were registered
+	endpoints, _ := provider.Records(ctx)
+	assert.True(t, testutils.SameEndpoints(newEndpoints, endpoints), "expected and actual endpoints don't match, expected=%v, actual=%v", newEndpoints, endpoints)
+
+	// make sure only one instance is de-registered
+	assert.Len(t, api.deregistered, 1)
+	assert.Equal(t, api.deregistered[0], "1.2.3.5", "wrong target de-registered")
+}
+
 func TestAWSSDProvider_ListNamespaces(t *testing.T) {
 	namespaces := map[string]*sdtypes.Namespace{
 		"private": {
diff --git a/source/gateway_test.go b/source/gateway_test.go
index 19d603a2..940622e9 100644
--- a/source/gateway_test.go
+++ b/source/gateway_test.go
@@ -20,7 +20,6 @@ import (
 	"strings"
 	"testing"
 
-	"sigs.k8s.io/external-dns/endpoint"
 	v1 "sigs.k8s.io/gateway-api/apis/v1"
 )
 
@@ -246,47 +245,3 @@ func TestIsDNS1123Domain(t *testing.T) {
 		})
 	}
 }
-
-func TestDualStackLabel(t *testing.T) {
-	tests := []struct {
-		desc      string
-		in        map[string](string)
-		setsLabel bool
-	}{
-		{
-			desc:      "empty-annotation",
-			setsLabel: false,
-		},
-		{
-			desc:      "correct-annotation-key-and-value",
-			in:        map[string]string{gatewayAPIDualstackAnnotationKey: gatewayAPIDualstackAnnotationValue},
-			setsLabel: true,
-		},
-		{
-			desc:      "correct-annotation-key-incorrect-value",
-			in:        map[string]string{gatewayAPIDualstackAnnotationKey: "foo"},
-			setsLabel: false,
-		},
-		{
-			desc:      "incorrect-annotation-key-correct-value",
-			in:        map[string]string{"FOO": gatewayAPIDualstackAnnotationValue},
-			setsLabel: false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.desc, func(t *testing.T) {
-			endpoints := make([]*endpoint.Endpoint, 0)
-			endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
-
-			rt := &gatewayHTTPRoute{}
-			rt.Metadata().Annotations = tt.in
-
-			setDualstackLabel(rt, endpoints)
-			got := endpoints[0].Labels[endpoint.DualstackLabelKey] == "true"
-
-			if got != tt.setsLabel {
-				t.Errorf("setDualstackLabel(%q); got: %v; want: %v", tt.in, got, tt.setsLabel)
-			}
-		})
-	}
-}
diff --git a/source/ingress_test.go b/source/ingress_test.go
index c1b58210..f948e665 100644
--- a/source/ingress_test.go
+++ b/source/ingress_test.go
@@ -44,12 +44,11 @@ func (suite *IngressSuite) SetupTest() {
 	fakeClient := fake.NewSimpleClientset()
 
 	suite.fooWithTargets = (fakeIngress{
-		name:        "foo-with-targets",
-		namespace:   "default",
-		dnsnames:    []string{"foo"},
-		ips:         []string{"8.8.8.8"},
-		hostnames:   []string{"v1"},
-		annotations: map[string]string{ALBDualstackAnnotationKey: ALBDualstackAnnotationValue},
+		name:      "foo-with-targets",
+		namespace: "default",
+		dnsnames:  []string{"foo"},
+		ips:       []string{"8.8.8.8", "2606:4700:4700::1111"},
+		hostnames: []string{"v1"},
 	}).Ingress()
 	_, err := fakeClient.NetworkingV1().Ingresses(suite.fooWithTargets.Namespace).Create(context.Background(), suite.fooWithTargets, metav1.CreateOptions{})
 	suite.NoError(err, "should succeed")
@@ -77,13 +76,6 @@ func (suite *IngressSuite) TestResourceLabelIsSet() {
 	}
 }
 
-func (suite *IngressSuite) TestDualstackLabelIsSet() {
-	endpoints, _ := suite.sc.Endpoints(context.Background())
-	for _, ep := range endpoints {
-		suite.Equal("true", ep.Labels[endpoint.DualstackLabelKey], "should set dualstack label to true")
-	}
-}
-
 func TestIngress(t *testing.T) {
 	t.Parallel()
 
@@ -212,10 +204,24 @@ func testEndpointsFromIngress(t *testing.T) {
 			},
 		},
 		{
-			title: "one rule.host two lb.IP and two lb.Hostname",
+			title: "one rule.host one lb.IPv6",
+			ingress: fakeIngress{
+				dnsnames: []string{"foo.bar"},
+				ips:      []string{"2606:4700:4700::1111"},
+			},
+			expected: []*endpoint.Endpoint{
+				{
+					DNSName:    "foo.bar",
+					RecordType: endpoint.RecordTypeAAAA,
+					Targets:    endpoint.Targets{"2606:4700:4700::1111"},
+				},
+			},
+		},
+		{
+			title: "one rule.host two lb.IP, two lb.IPv6 and two lb.Hostname",
 			ingress: fakeIngress{
 				dnsnames:  []string{"foo.bar"},
-				ips:       []string{"8.8.8.8", "127.0.0.1"},
+				ips:       []string{"8.8.8.8", "127.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"},
 				hostnames: []string{"elb.com", "alb.com"},
 			},
 			expected: []*endpoint.Endpoint{
@@ -224,6 +230,11 @@ func testEndpointsFromIngress(t *testing.T) {
 					RecordType: endpoint.RecordTypeA,
 					Targets:    endpoint.Targets{"8.8.8.8", "127.0.0.1"},
 				},
+				{
+					DNSName:    "foo.bar",
+					RecordType: endpoint.RecordTypeAAAA,
+					Targets:    endpoint.Targets{"2606:4700:4700::1111", "2606:4700:4700::1001"},
+				},
 				{
 					DNSName:    "foo.bar",
 					RecordType: endpoint.RecordTypeCNAME,
@@ -450,6 +461,30 @@ func testIngressEndpoints(t *testing.T) {
 				},
 			},
 		},
+		{
+			title:           "dualstack ingress",
+			targetNamespace: "",
+			ingressItems: []fakeIngress{
+				{
+					name:      "fake1",
+					namespace: namespace,
+					dnsnames:  []string{"example.org"},
+					ips:       []string{"8.8.8.8", "2001:DB8::1"},
+				},
+			},
+			expected: []*endpoint.Endpoint{
+				{
+					DNSName:    "example.org",
+					RecordType: endpoint.RecordTypeA,
+					Targets:    endpoint.Targets{"8.8.8.8"},
+				},
+				{
+					DNSName:    "example.org",
+					RecordType: endpoint.RecordTypeAAAA,
+					Targets:    endpoint.Targets{"2001:DB8::1"},
+				},
+			},
+		},
 		{
 			title:                  "ignore rules",
 			targetNamespace:        "",
diff --git a/source/skipper_routegroup_test.go b/source/skipper_routegroup_test.go
index 9b4c325a..68f70bc4 100644
--- a/source/skipper_routegroup_test.go
+++ b/source/skipper_routegroup_test.go
@@ -196,6 +196,28 @@ func TestEndpointsFromRouteGroups(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:   "Routegroup with hosts and destination IPv6 creates an endpoint",
+			source: &routeGroupSource{},
+			rg: createTestRouteGroup(
+				"namespace1",
+				"rg1",
+				nil,
+				[]string{"rg1.k8s.example"},
+				[]routeGroupLoadBalancer{
+					{
+						IP: "2001:DB8::1",
+					},
+				},
+			),
+			want: []*endpoint.Endpoint{
+				{
+					DNSName:    "rg1.k8s.example",
+					RecordType: endpoint.RecordTypeAAAA,
+					Targets:    endpoint.Targets([]string{"2001:DB8::1"}),
+				},
+			},
+		},
 		{
 			name:   "Routegroup with hosts and mixed destinations creates endpoints",
 			source: &routeGroupSource{},
@@ -224,6 +246,34 @@ func TestEndpointsFromRouteGroups(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:   "Routegroup with hosts and mixed destinations (IPv6) creates endpoints",
+			source: &routeGroupSource{},
+			rg: createTestRouteGroup(
+				"namespace1",
+				"rg1",
+				nil,
+				[]string{"rg1.k8s.example"},
+				[]routeGroupLoadBalancer{
+					{
+						Hostname: "lb.example.org",
+						IP:       "2001:DB8::1",
+					},
+				},
+			),
+			want: []*endpoint.Endpoint{
+				{
+					DNSName:    "rg1.k8s.example",
+					RecordType: endpoint.RecordTypeAAAA,
+					Targets:    endpoint.Targets([]string{"2001:DB8::1"}),
+				},
+				{
+					DNSName:    "rg1.k8s.example",
+					RecordType: endpoint.RecordTypeCNAME,
+					Targets:    endpoint.Targets([]string{"lb.example.org"}),
+				},
+			},
+		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			got := tt.source.endpointsFromRouteGroup(tt.rg)
@@ -787,37 +837,6 @@ func TestResourceLabelIsSet(t *testing.T) {
 	}
 }
 
-func TestDualstackLabelIsSet(t *testing.T) {
-	source := &routeGroupSource{
-		cli: &fakeRouteGroupClient{
-			rg: &routeGroupList{
-				Items: []*routeGroup{
-					createTestRouteGroup(
-						"namespace1",
-						"rg1",
-						map[string]string{
-							ALBDualstackAnnotationKey: ALBDualstackAnnotationValue,
-						},
-						[]string{"rg1.k8s.example"},
-						[]routeGroupLoadBalancer{
-							{
-								Hostname: "lb.example.org",
-							},
-						},
-					),
-				},
-			},
-		},
-	}
-
-	got, _ := source.Endpoints(context.Background())
-	for _, ep := range got {
-		if v, ok := ep.Labels[endpoint.DualstackLabelKey]; !ok || v != "true" {
-			t.Errorf("Failed to set resource label on ep %v", ep)
-		}
-	}
-}
-
 func TestParseTemplate(t *testing.T) {
 	for _, tt := range []struct {
 		name                     string