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
- How it works
- Prerequisites
- Permissions
- Authentication
- Installation
- Verify it works
- Configuration reference
- Supported record types & annotations
- Rate limits & tuning
- NCP-specific behavior & limitations
- Troubleshooting
- Cleanup
- Development
- Architecture
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:
| Component | Verified version |
|---|---|
| ExternalDNS | v0.21.0 |
| ExternalDNS Helm chart | 1.21.1 (provider: webhook) |
| Webhook protocol | v1 (application/external.dns.webhook+json;version=1) |
| Kubernetes | 1.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.
| Port | Bind | Purpose |
|---|---|---|
8888 | localhost | ExternalDNS ↔ webhook (sidecar, not externally exposed) |
8080 | 0.0.0.0 | Kubernetes 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 operation | Used for |
|---|---|
| list domains | discover manageable zones, build the domain filter |
| list records | read current state each reconcile |
| create / update / delete records | apply 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]
| Method | When to use | Trade-off |
|---|---|---|
| NKS Node IAM Role (server role) | Preferred on NKS clusters | No 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 keys | Non-NKS clusters, or when a scoped Sub Account key is preferred | Explicit and portable; you are responsible for storing (as a Secret) and rotating them |
- Static keys — set both
NCLOUD_ACCESS_KEY(orNCLOUD_ACCESS_KEY_ID) andNCLOUD_SECRET_KEY(orNCLOUD_SECRET_ACCESS_KEY), typically from a KubernetesSecret. - 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.
| Variable | Required | Default | Description |
|---|---|---|---|
NCLOUD_ACCESS_KEY / NCLOUD_ACCESS_KEY_ID | No* | — | NCP API access key |
NCLOUD_SECRET_KEY / NCLOUD_SECRET_ACCESS_KEY | No* | — | NCP API secret key |
NCLOUD_DNS_BASE_URL | No | https://globaldns.apigw.ntruss.com/dns/v1 | Global DNS OpenAPI endpoint |
NCLOUD_METADATA_API_ENDPOINT | No | http://169.254.169.254 | Instance metadata endpoint override (testing) |
DOMAIN_FILTER | No | all zones | Comma-separated list of zones to manage |
DRY_RUN | No | false | Skip API mutations, log only |
WEBHOOK_PORT | No | 8888 | Webhook server port (localhost) |
HEALTH_PORT | No | 8080 | Health server port (0.0.0.0) |
LOG_LEVEL | No | info | debug | 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:
| Annotation | Effect |
|---|---|
external-dns.alpha.kubernetes.io/hostname | Hostname(s) to publish |
external-dns.alpha.kubernetes.io/target | Override the record target |
external-dns.alpha.kubernetes.io/ttl | Record 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:
| Behavior | Value |
|---|---|
| Retried status codes | 429, 500, 502, 503, 504 only (other 4xx fail fast) |
| Retries | up to 3 (exponential backoff 1s → 2s → 4s) |
429 Retry-After honored | yes, capped at 60s |
| Per-request timeout | 10s |
| Pagination | 100/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
--intervalsized 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
| Symptom | Likely cause | Action |
|---|---|---|
Pod exits at startup with resolve NCP credentials … | Neither static keys nor an NKS server role resolved | Set the key env vars, or attach a Server-type Sub Account role with Global DNS permission to the node pool |
401/403 from the OpenAPI | Wrong 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 error | Hostname's zone is not in the account or not in DOMAIN_FILTER; the provider logs a warn and skips | Confirm the zone exists in NCP and is allowed by the filter |
domain has staged DNS changes; skipping external-dns mutations repeats | The 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.