Skip to content
---
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 DNSProvider for tenant self-service, cluster-scoped
    ClusterDNSProvider for 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 Source interface or the plan diffing 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.Config plus a credential source from the spec
    (see Credentials), build a DomainFilter, then call the existing
    providerfactory.Select
    and
    registryfactory.Select,
    and construct a Controller bound to this pipeline’s routed Source view (see
    Routing). Start its reconcile loop.
  • Update (spec changed, 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):

  1. 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 reference DNSProviders in its own
    namespace; it may reference any ClusterDNSProvider.

  2. Domain match (fallback) β€” when no reference annotation is present, the manager evaluates the
    record’s DNS name against every provider’s spec.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 populate RefObjects (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 their RefObjects. 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-DNSProvider enforcement sound.
  • Conflicting references on a merged name are resolved as in
    Conflict Resolution: skip the contested providers with a Warning event +
    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 credentialsRef is omitted and the
    role/identity is set via spec.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
    a Warning event 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 as ProviderInitialized,
    AuthFailed, ZoneNotFound), observedGeneration, lastSyncTime, and a records count.
  • Metrics gain a provider_resource label (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-run is honored globally.
  • Same record, multiple providers β€” supported only via explicit list reference, and only with
    distinct txtOwnerIds.

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 at kubectl 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 one ClusterDNSProvider before 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 --provider collides 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.