---
title: "Multiple Providers and Zones from a Single Deployment via CRDs"
version: v1alpha1
authors: "@mloiseleur"
creation-date: 2026-06-21
status: not-planned
---
Multiple Providers and Zones from a Single Deployment via CRDs¶
Table of Contents¶
Decision¶
Rejected after review on #6512:
- One deployment per configuration gets isolation, blast radius, credential separation and independent
upgrades from Kubernetes. One process would have to re-implement each in code, and turn ExternalDNS
into an operator. - Managing N configurations is a config-management problem: Helm / ArgoCD / generators, or a separate
operator such as openshift/external-dns-operator. - Split-horizon already works with
--annotation-prefix(docs). - The remaining gains (resource savings, cross-provider conflict detection) do not justify a breaking
major version.
The design below is kept for reference.
Summary¶
ExternalDNS currently binds one process to one provider+zone configuration, supplied as
CLI flags / env vars and resolved once at startup
(controller/execute.go).
Managing several providers, accounts, or zones requires one deployment per configuration.
This proposal moves provider+zone configuration into two new CRDs β DNSProvider (namespaced)
and ClusterDNSProvider (cluster-scoped) β modeled on cert-manager’s Issuer / ClusterIssuer.
A single ExternalDNS deployment watches these resources and runs one independent reconcile
pipeline per resource, created and torn down dynamically as the CRDs change.
This replaces the provider-selection flags (--provider, --domain-filter, --txt-owner-id,
registry flags, credential env, β¦) rather than living beside them: the CRD becomes the single config
mechanism for providers and zones. Migration is a mechanical near-1:1 flagβfield mapping, but a
breaking change gated on the next major version. Global flags (--policy, --interval, source
selection, logging, metrics, leader election) stay on the CLI.
This addresses #1961
Motivation¶
#1961 is one of the most requested
features in the project. The recurring pain across the thread:
- Multiple accounts / subscriptions β separate AWS accounts or Azure subscriptions, each needing
its own role/credentials, force one deployment each. - Split-horizon DNS β the same records must be published to an internal zone (e.g.
rfc2136/
Active Directory) and a public zone (e.g. Route53). Already possible with--annotation-prefix. - Multi-tenancy β in shared clusters, each tenant runs its own deployment; there is no clean way
to say “only namespace X may publish to zone Y”. - Per-zone credentials β operators want to revoke a single zone’s key without touching others.
- Operational overhead β N deployments mean N sets of RBAC, metrics, dashboards, upgrades.
Today’s only mitigations β --domain-filter repetition or --regex-domain-filter β share a
single provider and credential set, so they do not cover multi-account, multi-provider, or
multi-tenant cases.
Goals¶
- Run multiple provider+zone configurations from one deployment, configured via CRDs.
- Configuration changes (add / update / remove a provider) take effect without restarting the
deployment. - Support multi-tenant isolation: namespaced
DNSProviderfor tenant self-service, cluster-scoped
ClusterDNSProviderfor platform-wide zones. - Support split-horizon: a single source record may be published by more than one provider.
- Reuse the existing provider/registry factories rather than reimplementing provider wiring.
- Make the CRD the single provider/zone configuration mechanism, with a mechanical, near-1:1
migration from the existing provider-selection flags.
Non-Goals¶
- Not adding new in-tree providers. This is an orchestration layer over existing providers and the
webhook mechanism; it does not alter the “no new in-tree providers” gate from
#4347. - Not a fully-typed per-provider CRD schema for all 20+ providers (see
Alternatives). - Not changing the global controller flags (
--policy,--interval, source selection, logging,
metrics, leader election); only the provider-selection flags move to the CRD. - Not redesigning the
Sourceinterface or theplandiffing logic. - Not solving cross-deployment leader election beyond what
proposal 001 already covers.
Proposal¶
A single ExternalDNS controller becomes a small operator that, instead of building one pipeline at
startup, watches DNSProvider / ClusterDNSProvider resources and maintains a set of pipelines β
one per resource. Each pipeline is the existing
Source β Provider β Registry β Plan β ApplyChanges flow, parameterized by a per-resource
configuration synthesized from the CRD spec.
ββββββββββββββββββββββββββββββββββββββββββββββββ
β ExternalDNS deployment β
β β
DNSProvider ββββββββββββΊ watch βββΊ pipeline manager β
ClusterDNSProvider βββββΊ β β β
β β βΌ β
β β routing pass βββ¬β pipeline A β
shared informers βββββββΊ Source ββ (snapshot) ββ pipeline B β
(svc/ingress/routes) β store ββ pipeline C β
β β
β each pipeline: own provider+registry, β
β own txt-owner-id, own loop β
ββββββββββββββββββββββββββββββββββββββββββββββββ
The Source store (Kubernetes informers for Services / Ingresses / Gateway routes / CRDs) is shared:
informers are created once, the manager queries the store on each refresh, and the
routing pass slices that single endpoint set into per-pipeline subsets.
User Stories¶
Story 1: Multiple AWS accounts (#1961, nikolaiderzhak)¶
As a platform engineer, I run workloads spanning three AWS accounts. I create three
ClusterDNSProvider resources, each with a Route53 config and a credentialsRef/IRSA role for its account.
One ExternalDNS deployment publishes records into the right account based on the zone each hostname
falls under β no more three deployments to upgrade in lockstep.
Story 2: Split-horizon (#1961, anthonysomerset, Px-x64)¶
As an operator, app.example.com must resolve internally via rfc2136 (Active Directory) and
externally via Route53. I create two ClusterDNSProvider resources covering the same domain and add a
list-valued reference annotation on the Ingress so both publish the record.
Story 3: Multi-tenant self-service (#1961, sagikazarmark)¶
As a cluster admin, I let team foo manage DNS for foo.example.com only. I create a namespaced
DNSProvider in namespace foo scoped to that zone. Resources in foo reference it; resources in
other namespaces cannot use it. RBAC on the DNSProvider kind controls who may publish where.
Story 4: Per-zone credential rotation (#1961, rumstead)¶
As an operator, one zone’s API key is leaking quota. I rotate or revoke only that
DNSProvider’s referenced Secret; other zones keep running untouched.
API¶
Two CRDs in the existing API group externaldns.k8s.io/v1alpha1. They are identical in spec,
differing only in scope (Namespaced vs Cluster), mirroring cert-manager’s Issuer/ClusterIssuer.
apiVersion: externaldns.k8s.io/v1alpha1
kind: ClusterDNSProvider
metadata:
name: route53-prod
spec:
# Provider name β resolved by the existing provider factory.
type: aws
# Optional: zones this provider is authoritative for. Used for implicit
# domain-match routing and to scope the provider's Records() calls.
domainFilter:
include:
- prod.example.com
exclude:
- internal.prod.example.com
# Registry / ownership. Defaults: registry "txt", txtOwnerId = metadata.name.
registry:
type: txt
txtOwnerId: route53-prod # MUST be unique per provider; defaults to name
txtPrefix: "edns-"
# Per-provider configuration. Typed common fields above; provider-specific
# settings passed through as a flag-equivalent map (thin wrapper, see Behavior).
config:
aws-zone-type: public
aws-assume-role: "arn:aws:iam::111122223333:role/external-dns"
# Credentials, kept distinct in-memory per provider (per-pipeline, not global env).
credentialsRef:
name: route53-prod-credentials # Secret; keys read by this pipeline only
status:
conditions:
- type: Ready
status: "True"
reason: ProviderInitialized
observedGeneration: 3
lastSyncTime: "2026-06-21T10:00:00Z"
records: 42
The namespaced form is identical:
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSProvider
metadata:
name: team-foo-zone
namespace: foo
spec:
type: cloudflare
domainFilter:
include:
- foo.example.com
credentialsRef:
name: cloudflare-foo-token # Secret in namespace foo
The CRD is the only way to configure providers and zones; there is no mode flag. At least one
DNSProvider / ClusterDNSProvider must exist for the deployment to publish records. The
provider-selection flags (--provider, --domain-filter, --txt-owner-id, --txt-prefix, registry
flags, credential env, β¦) are removed; only genuinely global flags remain (--policy default,
--interval default, --log-level, --metrics-address, --events, leader election, source selection).
Migration¶
Each removed flag maps near-1:1 to a CRD field (--provider β spec.type, --domain-filter β
spec.domainFilter.include, --txt-owner-id β spec.registry.txtOwnerId, provider-specific flags β
spec.config, credential env β spec.credentialsRef). An existing deployment becomes one
ClusterDNSProvider plus the unchanged global flags. Breaking, gated on the next major version.
Behavior¶
Pipeline Lifecycle¶
A pipeline manager watches both CRD kinds:
- Add β synthesize a per-provider
externaldns.Configplus a credential source from the spec
(see Credentials), build aDomainFilter, then call the existing
providerfactory.Select
and
registryfactory.Select,
and construct aControllerbound to this pipeline’s routedSourceview (see
Routing). Start its reconcile loop. - Update (
specchanged,observedGeneration < generation) β rebuild the pipeline; swap atomically. - Delete β stop the loop, drain in-flight work. Records are not deleted on CRD removal; ownership
cleanup remains an explicit operator action (consistent with how stopping a deployment behaves today).
Each pipeline runs its own RunOnce on the global --interval, and is additionally triggered by shared
source events (--events), throttled by --min-event-sync-interval exactly as the single pipeline is today.
Routing Endpoints to a Provider¶
Routing is a single shared pass owned by the pipeline manager, not a filter each pipeline applies to
itself: a pipeline only sees its own spec.domainFilter and cannot tell that another provider matches the
same record, which is what Conflict Resolution needs.
The manager holds every DNSProvider / ClusterDNSProvider spec, so on each shared-source refresh it
builds a routing table (endpoint β []pipeline) over the full pre-merge endpoint set and publishes it
as an immutable snapshot. Pipelines keep their independent loops and read their own subset from the current
snapshot when their RunOnce fires β the snapshot is rebuilt on source and CRD events, not per pipeline
tick, so pipelines on different schedules route consistently.
The snapshot is a pipeline’s only path to source endpoints: each pipeline is built with a thin Source
view backed by its own subset, rather than a handle on the shared store, so Controller.RunOnce keeps
calling Source.Endpoints() unchanged.
The table is built with a two-tier rule (explicit reference wins, domain match is the fallback):
-
Explicit reference β a source resource may name one or more providers via annotation:
metadata: annotations: # namespaced DNSProvider in the resource's namespace external-dns.kubernetes.io/provider: "team-foo-zone" # cluster-scoped, comma-separated for split-horizon external-dns.kubernetes.io/cluster-provider: "route53-prod,rfc2136-internal"When present, only the named providers consider the record. A list publishes the same record
through several providers (Story 2). A namespaced resource may only referenceDNSProviders in its own
namespace; it may reference anyClusterDNSProvider. -
Domain match (fallback) β when no reference annotation is present, the manager evaluates the
record’s DNS name against every provider’sspec.domainFilter. The record is routed only when
exactly one provider matches β see Conflict Resolution.
This keeps zero-annotation flows working (records land on the provider that owns their zone) while
giving explicit control where needed.
Endpoint Provenance and Merge Ordering¶
Explicit-reference routing needs each endpoint’s source object. Two existing mechanics must be pinned down:
- Provenance via
RefObjects. The reference annotation is read from
Endpoint.RefObjects(),
not the DNS name β so every routable source must populateRefObjects(the gap
#6492 closed for ~17 sources). Sources with
no Kubernetes object (connector, static) get domain-match routing only. - Route before
MergeEndpoints.
MergeEndpoints
collapses same-name records into one endpoint with the union of theirRefObjects. Routing runs on
the pre-merge set (each endpoint = one source object, one annotation set); merge then runs within
each pipeline’s subset. This also keeps namespaced-DNSProviderenforcement sound. - Conflicting references on a merged name are resolved as in
Conflict Resolution: skip the contested providers with aWarningevent +
metric.
Credentials¶
Each provider’s credentialsRef points to a Secret. Many providers read credentials from process
environment today, so running N providers in one process needs an explicit per-pipeline credential
boundary β never a mutated shared os.Environ():
- A per-pipeline key/value source (from
credentialsRef+spec.config) is passed into provider
construction at the factory boundary; providers read credentials from it, not global env. - Ambient identity (IRSA, Workload Identity) still works when
credentialsRefis omitted and the
role/identity is set viaspec.config.
This is the main factory-layer change; per-provider logic is reused unchanged.
Ownership Isolation¶
Each provider gets its own txtOwnerId, defaulting to metadata.name. This keeps TXT registry
markers disjoint across pipelines, so two providers managing overlapping zones do not fight over each
other’s ownership records. The validating webhook (below) rejects duplicate txtOwnerId values within
the cluster to prevent silent record takeover.
Conflict Resolution¶
When two providers match the same record by domain (no explicit reference), this is ambiguous. There is
no winner. Rules:
- Disjoint
domainFilters are the recommended configuration; the validating webhook warns at
admission on overlapping include filters across providers of the same scope. That is a static check on
specs, so the routing pass repeats it per record to catch filters that only collide for some names. - For genuine overlap (e.g. split-horizon by design), users must use the explicit reference
annotation β the only way to publish one record through several providers. - On several domain matches the record is published by none of them, and the shared routing pass emits
aWarningevent on the source resource plus a metric naming the contenders β rather than publishing
twice or picking one non-deterministically.
Status and Observability¶
- Each CRD reports
status.conditions(Ready, with reasons such asProviderInitialized,
AuthFailed,ZoneNotFound),observedGeneration,lastSyncTime, and arecordscount. - Metrics gain a
provider_resourcelabel (the CRD name) so existing per-sync metrics are reported
per pipeline. Cardinality is bounded by the number of CRDs. - Source-resource events reference the provider that published (or skipped) them.
Edge Cases¶
- No matching provider β record is unmanaged; surfaced via metric + optional source event.
- CRD references a missing Secret / zone β pipeline enters
Ready=False; other pipelines unaffected. - Leader election β with multiple replicas, proposal 001 governs which
replica is active; pipelines run on the leader. No new cross-pipeline election is introduced. --once/ dry-run β apply per pipeline;--dry-runis honored globally.- Same record, multiple providers β supported only via explicit list reference, and only with
distincttxtOwnerIds.
Drawbacks¶
- New controller surface β CRD watching, a pipeline manager with dynamic lifecycle, a validating
webhook, and status reporting are substantial new code and test surface. - Resource usage β N pipelines means N provider clients and N reconcile loops in one process;
memory/CPU grow with provider count (still typically cheaper than N deployments). - Per-pipeline credential handling at the factory boundary touches a sensitive, well-exercised code path.
- Thin-wrapper config is less type-safe than per-provider schemas: provider-specific keys are a
passthrough map, so misconfiguration surfaces at pipeline init, not atkubectl apply(mitigated by
status conditions and the validating webhook). - Breaking change. Every deployment β including the common single-provider case β must install the
CRDs and apply oneClusterDNSProviderbefore publishing.
Alternatives¶
Alternative 1: Status quo β one deployment per provider¶
Description: Keep the current model; operators deploy ExternalDNS once per provider+zone configuration.
Pros:
- Zero new code
- Strong isolation between configurations (separate processes, RBAC, blast radius)
- Simple mental model
Cons:
- The exact overhead #1961 is about β NΓ RBAC, metrics, dashboards, upgrades
- No clean multi-tenant story (no namespace-scoped authorization)
Recommendation: β Retained β see Decision.
Alternative 2: Fully-typed per-provider CRD schema¶
Description: A strongly-typed spec per provider, validated at kubectl apply, instead of the
thin-wrapper config passthrough map.
Pros:
- Best UX and validation (errors surface at apply time, not pipeline init)
- Self-documenting schema per provider
Cons:
- Enormous surface across 20+ providers
- Directly conflicts with the out-of-tree direction of
#4347 - Unmaintainable as providers move out of tree
Recommendation: β Not recommended as the baseline β the thin-wrapper config map can be tightened
to typed fields per provider later if desired.
Alternative 3: --domain-filter / --regex-domain-filter only¶
Description: Rely on the existing domain-filter flags to cover multiple zones from one deployment.
Pros:
- Exists today, no new code
- Familiar to current users
Cons:
- Shares a single provider and a single credential set
- Cannot serve multi-account, multi-provider, or multi-tenant cases
Recommendation: π‘ Complementary, not a substitute β useful within a single provider, orthogonal to
this proposal.
Alternative 4: Cluster-scoped CRD only (no namespaced kind)¶
Description: Ship only ClusterDNSProvider; rely on RBAC over the cluster resource for tenancy.
Pros:
- Smaller API surface
- Fewer resources to reason about
Cons:
- No tenant self-service
- Multi-tenancy depends entirely on RBAC over a cluster resource
- The issue explicitly asks for namespace-scoped authorization
Recommendation: β Not recommended β namespaced + cluster-scoped chosen to match the cert-manager
model the issue references. Cluster scope alone cannot express per-namespace ownership.
Alternative 5: Additive opt-in mode, keep the flags¶
Description: Keep the provider-selection flags working and add the CRD path behind a mode flag
(e.g. --provider=crd), so existing single-provider deployments are unaffected.
Pros:
- Non-breaking; gradual adoption
- Existing deployments need no migration
Cons:
- Two configuration systems coexist permanently β more code, docs, and support surface
- A mode flag overloaded onto
--providercollides with existing CRD source and CRD registry
terminology - The dual path becomes the long-term shape, not a transition
Recommendation: β Not recommended β the mechanical near-1:1 migration is outweighed by collapsing to
a single configuration mechanism.