ExternalDNS

July 23, 2026 · View on GitHub

external-dns-navercloud-webhook is an ExternalDNS webhook provider for NAVER Cloud Platform (NCP) Global DNS.

It synchronizes DNS records in NCP Global DNS from Kubernetes Service and Ingress resources, so DNS entries follow your cluster state automatically.

Community / out-of-tree provider. This is an independent webhook provider that runs as a sidecar to ExternalDNS. It is not maintained by the kubernetes-sigs/external-dns project. Issues and pull requests are handled in this repository.

Contents

Compatibility

This webhook implements ExternalDNS webhook protocol v1. Choose an ExternalDNS version compatible with your Kubernetes version according to the upstream compatibility matrix.

The current release has been verified with:

ComponentVerified version
ExternalDNSv0.21.0
ExternalDNS Helm chart1.21.1 (provider: webhook)
Webhook protocolv1 (application/external.dns.webhook+json;version=1)
Kubernetes1.34, 1.35.3

How it works

ExternalDNS owns reconciliation; this webhook translates ExternalDNS intent into NCP Global DNS OpenAPI calls. ExternalDNS watches Service/Ingress resources, computes the desired records, and drives the webhook over protocol v1; the webhook signs and forwards each operation to the Global DNS OpenAPI. The webhook listens on localhost only and is reached exclusively by the ExternalDNS container in the same pod.

PortBindPurpose
8888localhostExternalDNS ↔ webhook (sidecar, not externally exposed)
80800.0.0.0Kubernetes liveness/readiness probe (GET /healthz)

The webhook implements the four provider endpoints required by ExternalDNS (GET /, GET /records, POST /records, POST /adjustendpoints) plus the health endpoint. Following ExternalDNS guidance, transient failures return 5xx (retried by the controller on the next reconcile) and permanent failures return 4xx.

Prerequisites

  • An NCP account with Global DNS enabled.
  • A domain zone created in the NCP Global DNS console.
  • An identity authorized to manage that zone (see Permissions).
  • API credentials — static IAM keys or an NKS Node IAM Role (see Authentication).

Permissions

Whichever identity the webhook signs requests with must be allowed to manage NCP Global DNS for the target zone. The provider calls:

OpenAPI operationUsed for
list domainsdiscover manageable zones, build the domain filter
list recordsread current state each reconcile
create / update / delete recordsapply desired changes
apply (per domain)publish staged changes

Grant the minimum policy that covers these Global DNS read and write operations, and attach it to the identity through an NCP Sub Account policy. Follow least privilege: scope the policy to Global DNS only, and avoid attaching broader account-wide permissions to the node pool or service identity.

The exact NCP Sub Account policy name and any per-zone restriction options should be confirmed against current NCP IAM documentation for your account tier before production rollout.

Authentication

Credentials are resolved through a chain — the first source that yields a valid pair wins, and temporary credentials are refreshed automatically ahead of expiry.

flowchart TD
    A["Env keys present?<br/>NCLOUD_ACCESS_KEY(_ID) + NCLOUD_SECRET(_ACCESS)_KEY"] -->|Yes| B[Use static keys]
    A -->|No| C["NKS Node IAM Role<br/>(server role via instance metadata)"]
    C -->|temporary creds| D[Auto-refresh before expiry]
MethodWhen to useTrade-off
NKS Node IAM Role (server role)Preferred on NKS clustersNo long-lived secret to store or rotate; but every pod on the node can read the role's credentials via the metadata API — use dedicated/tainted node pools
Static IAM keysNon-NKS clusters, or when a scoped Sub Account key is preferredExplicit and portable; you are responsible for storing (as a Secret) and rotating them
  1. Static keys — set both NCLOUD_ACCESS_KEY (or NCLOUD_ACCESS_KEY_ID) and NCLOUD_SECRET_KEY (or NCLOUD_SECRET_ACCESS_KEY), typically from a Kubernetes Secret.
  2. NKS Node IAM Role (server role) — attach a Server-type Sub Account role with Global DNS permission to the node pool at creation time, and omit the key env vars. The provider reads temporary credentials from the instance metadata API and refreshes them before they expire.

On startup the webhook logs which provider resolved the credentials (NCP credentials resolved provider=...), so you can confirm the active method.

Installation

Deploy as a sidecar of ExternalDNS using the upstream Helm chart:

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm install external-dns external-dns/external-dns \
  --namespace external-dns --create-namespace \
  --version 1.21.1 -f values.yaml

Minimal values.yaml:

sources:
  - service
  - ingress

logLevel: info

# Required: prevents CNAME + TXT ownership-record name collision (RFC 1034).
txtPrefix: "_edns."
txtOwnerId: "external-dns"

domainFilters:
  - example.com

# NCP stages record changes and triggers a per-domain async apply, so
# ApplyChanges needs headroom beyond the default webhook deadline. This must
# exceed the provider's per-domain execution backstop (75s).
extraArgs:
  - --webhook-provider-read-timeout=90s

# Must exceed the webhook's graceful-shutdown wait (90s) so an in-flight
# staging+apply can roll forward to completion during pod termination.
terminationGracePeriodSeconds: 120

provider:
  name: webhook
  webhook:
    image:
      repository: nks.kr.ncr.ntruss.com/nks/external-dns-navercloud-webhook
      tag: latest
    env:
      # Omit both key env vars to use the NKS Node IAM Role (server role)
      # attached to the node pool instead of static keys.
      - name: NCLOUD_ACCESS_KEY
        valueFrom:
          secretKeyRef:
            name: ncloud-credentials
            key: access-key
      - name: NCLOUD_SECRET_KEY
        valueFrom:
          secretKeyRef:
            name: ncloud-credentials
            key: secret-key
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 3
      periodSeconds: 5

A complete example ships in deploy/values-example.yaml.

When using static keys, create the referenced Secret first:

kubectl create secret generic ncloud-credentials \
  --namespace external-dns \
  --from-literal=access-key="$NCLOUD_ACCESS_KEY" \
  --from-literal=secret-key="$NCLOUD_SECRET_KEY"

Verify it works

Deploy a sample Service annotated with a hostname inside your managed zone:

apiVersion: v1
kind: Service
metadata:
  name: nginx
  annotations:
    external-dns.alpha.kubernetes.io/hostname: nginx.example.com
    external-dns.alpha.kubernetes.io/ttl: "300"
spec:
  type: LoadBalancer
  selector:
    app: nginx
  ports:
    - port: 80

Within one reconcile interval (default 1 minute) ExternalDNS calls the webhook and the provider creates the record. Confirm it:

# Watch the controller decide and the webhook apply
kubectl -n external-dns logs deploy/external-dns --all-containers -f

# Resolve via the zone's authoritative nameserver
dig nginx.example.com

# Or check the record in the NCP console:
#   Global DNS > select the zone > Records

ExternalDNS also writes a TXT ownership record with the record type encoded after your --txt-prefix (e.g. _edns.a-nginx.example.com for an A record). With the default upsert-only policy, deleting the Service leaves both records in place. Set policy: sync to remove them on the next reconcile.

Configuration reference

All configuration is via environment variables on the webhook container.

VariableRequiredDefaultDescription
NCLOUD_ACCESS_KEY / NCLOUD_ACCESS_KEY_IDNo*NCP API access key
NCLOUD_SECRET_KEY / NCLOUD_SECRET_ACCESS_KEYNo*NCP API secret key
NCLOUD_DNS_BASE_URLNohttps://globaldns.apigw.ntruss.com/dns/v1Global DNS OpenAPI endpoint
NCLOUD_METADATA_API_ENDPOINTNohttp://169.254.169.254Instance metadata endpoint override (testing)
DOMAIN_FILTERNoall zonesComma-separated list of zones to manage
DRY_RUNNofalseSkip API mutations, log only
WEBHOOK_PORTNo8888Webhook server port (localhost)
HEALTH_PORTNo8080Health server port (0.0.0.0)
LOG_LEVELNoinfodebug | info | warn | error

* See Authentication: when a complete key pair is not provided, the provider falls back to the NKS Node IAM Role.

DOMAIN_FILTER and the ExternalDNS --domain-filter flag are complementary: the webhook only ever returns zones that pass its own filter intersected with the zones present in your NCP account.

Supported record types & annotations

Record types: A, AAAA, CNAME, MX, TXT. (SOA/NS defaults are not managed.)

This provider has no NCP-specific annotations. It honors the standard ExternalDNS annotations, including:

AnnotationEffect
external-dns.alpha.kubernetes.io/hostnameHostname(s) to publish
external-dns.alpha.kubernetes.io/targetOverride the record target
external-dns.alpha.kubernetes.io/ttlRecord TTL (defaults to 300s when unset or ≤0)

Rate limits & tuning

Each reconcile reads current state (list domains + list records, paginated at 100 records/page) and applies any diff. Read traffic dominates; writes occur only when Kubernetes resources change.

The NCP client bounds its own request volume:

BehaviorValue
Retried status codes429, 500, 502, 503, 504 only (other 4xx fail fast)
Retriesup to 3 (exponential backoff 1s → 2s → 4s)
429 Retry-After honoredyes, capped at 60s
Per-request timeout10s
Pagination100/page, hard cap 500 pages/zone

To reduce OpenAPI call volume at the source, tune ExternalDNS:

  • --interval=<duration> — raise from the 1m default to lengthen the reconcile period.
  • --events — reconcile on Kubernetes resource events instead of (or alongside) long polling.
  • --domain-filter / DOMAIN_FILTER — scope to only the zones you manage.

NCP Global DNS OpenAPI per-account/zone rate limits and quotas should be confirmed with NCP for your account tier, then --interval sized accordingly.

NCP-specific behavior & limitations

--txt-prefix is required

ExternalDNS creates a TXT ownership record alongside each managed record. For a CNAME record, that TXT would share the same name, which violates RFC 1034 (a CNAME cannot coexist with other record types) — and NCP Global DNS correctly rejects it.

You must set --txt-prefix in ExternalDNS (for example --txt-prefix=_edns.).

Staged changes & per-domain apply

NCP Global DNS stages record create/update/delete operations and then applies them at the domain level. This provider batches changes per domain and triggers one async apply after staging. If the async apply trigger fails after staging succeeded, the provider retries apply-only before returning an error. If a domain already has staged or unapplied changes, the provider skips new mutations for that domain until those changes are applied or rolled back.

Once the provider starts mutating a domain it rolls forward: that domain's staging and apply trigger continue — even if ExternalDNS disconnects mid-request or the pod begins graceful shutdown — up to a backstop deadline sized above the worst sequential staging+apply path, so a cancellation on that bounded path does not strand staged changes. Domains whose execution has not started yet are deferred to the next reconcile.

Because apply timing depends on this behavior, ExternalDNS must run with --webhook-provider-read-timeout=90s and the pod with terminationGracePeriodSeconds: 120 (see the values example). Smaller values can report a slow-but-successful apply as a failure, or cut off an in-flight roll-forward during shutdown.

Multi-cluster ownership

Multiple clusters can manage the same zone safely as long as each ExternalDNS instance uses a distinct --txt-owner-id. Ownership is tracked via TXT registry records, so one cluster does not delete or overwrite another cluster's records.

Troubleshooting

SymptomLikely causeAction
Pod exits at startup with resolve NCP credentials …Neither static keys nor an NKS server role resolvedSet the key env vars, or attach a Server-type Sub Account role with Global DNS permission to the node pool
401/403 from the OpenAPIWrong keys, role lacks Global DNS permission, or node clock skew (the signature is timestamp-based)Verify permissions and ensure node time is NTP-synced
Record never appears, no errorHostname's zone is not in the account or not in DOMAIN_FILTER; the provider logs a warn and skipsConfirm the zone exists in NCP and is allowed by the filter
domain has staged DNS changes; skipping external-dns mutations repeatsThe domain has unapplied staged changes (failed apply, or manual edits in console)Apply or roll back the staged changes (see below); the provider resumes next reconcile
CNAME creation rejected--txt-prefix not set → TXT/CNAME name collision (RFC 1034)Set --txt-prefix

Recovering from stuck staged changes. If staged changes remain on a domain, apply or roll back manually in the NCP console (Global DNS → select the zone → apply or rollback) or via the Global DNS OpenAPI. The provider resumes managing the domain once no staged changes remain.

Avoid editing records of an ExternalDNS-managed zone in the NCP console while the provider is running: NCP applies staged changes per domain, so a provider-triggered apply also publishes any changes staged manually for that domain.

Raise LOG_LEVEL=debug on the webhook for per-request detail.

Cleanup

# Remove sample workloads first so ExternalDNS deletes their records
kubectl delete service nginx

# Then uninstall the controller + webhook
helm uninstall external-dns

# Optionally remove the credentials Secret
kubectl -n external-dns delete secret ncloud-credentials

Records that were created with --policy=upsert-only are intentionally retained in the zone and must be removed in the NCP console if no longer wanted.

Development

make build        # Build binary
make test         # Run tests with the race detector
make lint         # Run golangci-lint
make docker-build # Build the container image (linux/amd64)

End-to-end tests spin up a kind cluster, deploy ExternalDNS + this webhook, and exercise real NCP Global DNS API calls against a dedicated test zone. They are gated behind the e2e build tag and require NCP credentials and a test zone:

go test -tags=e2e ./test/e2e/...

Architecture

cmd/webhook/       Entry point — two HTTP servers, graceful shutdown
internal/ncp/      NCP Global DNS API client (HMAC-SHA256 auth, retry/backoff,
                   credential chain: env keys + NKS server role)
internal/provider/ ExternalDNS provider logic (records, apply, cache)
internal/server/   Webhook HTTP server (protocol v1)

Module path: github.com/NaverCloudPlatform/external-dns-navercloud-webhook.

License

See LICENSE.

Acknowledgements

Built on the ExternalDNS webhook provider interface by the kubernetes-sigs/external-dns project.