A webhook provider for ExternalDNS that manages DNS records in a UniFi Network controller. ExternalDNS keeps DNS in sync with your Kubernetes Ingresses and Services; this provider applies those records to UniFi's built-in DNS via the Network Integration API.
| Component | Minimum version |
|---|---|
| ExternalDNS | v0.21.0 |
| UniFi OS | 5.x |
| UniFi Network | 10.3.58 |
The provider runs as a sidecar alongside the ExternalDNS controller. It speaks the ExternalDNS webhook protocol on one side and the UniFi Network Integration API (/proxy/network/integration/v1/..., specifically the DNS Policies endpoints) on the other. It reaches UniFi one of two ways:
- Local (default) — connects directly to the controller on your network.
- Cloud connector — proxies through
api.ui.comfor consoles you can't reach on the LAN. See Cloud connector.
Domain filtering is handled by the ExternalDNS controller, not by this webhook — see Domain filtering.
UniFi uses dnsmasq as its DNS backend, so the provider inherits its constraints:
- Wildcards (
*.example.com) are not supported. - One CNAME per name. The webhook reconciles this transparently:
- creating a CNAME where one already exists evicts the existing record first;
- if ExternalDNS sends multiple targets for a single CNAME, only the first is used and the rest are dropped with a warning.
Every request authenticates with an API key; username/password auth is not supported.
Local controller — log into your console by IP, then go to Settings → Control Plane → Integrations → Create API Key and copy the key.
Only Super Admins can create API keys, but a key keeps working after the user is downgraded. For least privilege, create a dedicated
external-dnsuser, generate its key while it's a Super Admin, then drop it to Site Admin — that's enough to manage DNS records.
Cloud connector — create an account-level key in the UniFi Site Manager under account settings → API. This is different from a per-console local key.
apiVersion: v1
kind: Secret
metadata:
name: unifi-dns-secret
stringData:
UNIFI_API_KEY: <your-api-key>Add the ExternalDNS chart repository:
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/Create a values file (external-dns-unifi-values.yaml):
fullnameOverride: external-dns-unifi
provider:
name: webhook
webhook:
image:
repository: ghcr.io/home-operations/external-dns-unifi-webhook
tag: main # replace with a versioned release tag
env:
- name: UNIFI_HOST
value: https://unifi.internal # your UniFi controller, or https://api.ui.com for the cloud connector
- name: UNIFI_API_KEY
valueFrom:
secretKeyRef:
name: unifi-dns-secret
key: UNIFI_API_KEY
livenessProbe:
httpGet:
path: /healthz
port: http-webhook
initialDelaySeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /readyz
port: http-webhook
initialDelaySeconds: 10
timeoutSeconds: 5
triggerLoopOnEvent: true
policy: sync
sources:
- gateway-httproute
- service
txtOwnerId: main
txtPrefix: k8s.main.%{record_type}-
domainFilters:
- example.com # replace with your domain
serviceMonitor:
enabled: trueInstall:
helm install external-dns-unifi external-dns/external-dns \
-f external-dns-unifi-values.yaml --version 1.21.1 -n external-dnsSee the chart values for additional options.
| Variable | Description | Default |
|---|---|---|
UNIFI_HOST |
Controller address, or https://api.ui.com for the cloud connector (required). |
— |
UNIFI_API_KEY |
API key used to authenticate (required). Local and cloud keys differ — see below. | — |
UNIFI_SITE |
Site name (e.g. default) or site UUID. Resolved to the API's UUID at startup. |
default |
UNIFI_CONSOLE_ID |
Console ID. Setting this routes requests through the api.ui.com cloud connector. |
— |
UNIFI_SKIP_TLS_VERIFY |
Skip TLS verification. Ignored in cloud mode. | true |
UNIFI_CA_CERT |
Path to a PEM bundle of extra trusted CAs (alternative to skipping verification). | — |
UNIFI_APPLY_WORKERS |
Maximum concurrent record operations during a reconcile. | 5 |
UNIFI_RETRY_ATTEMPTS |
Total attempts per request (including the first). | 3 |
UNIFI_RETRY_INITIAL_DELAY |
Initial backoff before the first retry. | 500ms |
UNIFI_RETRY_MAX_DELAY |
Maximum backoff between retries (also caps Retry-After). |
10s |
To manage a remote console without exposing the controller on your LAN, set UNIFI_CONSOLE_ID and point UNIFI_HOST at https://api.ui.com. Requests are then proxied through /v1/connector/consoles/{consoleId}/proxy/network/integration/v1/.... The Integration API surface is identical to a local connection — only the routing prefix changes.
- Use an account-level API key from the UniFi Site Manager, not a per-console local key.
- Find your console ID via the Site Manager API (
GET https://api.ui.com/v1/hosts) or the console URL in unifi.ui.com. UNIFI_SKIP_TLS_VERIFYis ignored —api.ui.compresents a publicly-trusted certificate, and skipping verification would expose your API key.- Reconciliation depends on Ubiquiti's API availability and rate limits. The built-in backoff handles
429s, but a local connection is preferable when reachable.
Configure --domain-filter (and its variants) on the ExternalDNS controller, not on this webhook. UniFi has no zone concept the webhook could narrow against, so it follows the ExternalDNS GetDomainFilter contract and leaves filtering to the controller.
| Variable | Description | Default |
|---|---|---|
SERVER_HOST |
Webhook server bind address. | localhost |
SERVER_PORT |
Webhook server port. | 8888 |
SERVER_READ_TIMEOUT |
Request read timeout. | 60s |
SERVER_READ_HEADER_TIMEOUT |
Read-header timeout (Slowloris mitigation). | 5s |
SERVER_WRITE_TIMEOUT |
Response write timeout. | 60s |
SERVER_IDLE_TIMEOUT |
Keep-alive idle timeout. | 120s |
SERVER_MAX_HEADER_BYTES |
Maximum request header size. | 65536 |
SERVER_MAX_BODY_BYTES |
Maximum POST body size before returning 413. |
5242880 (5 MiB) |
HEALTH_SERVER_ADDR |
Address for the /metrics, /healthz, /readyz server. |
0.0.0.0:8080 |
READINESS_CACHE_TTL |
How long /readyz caches the upstream probe result. |
30s |
PPROF_ENABLED |
Mount /debug/pprof/* on the health server (not in prod). |
false |
LOG_LEVEL |
Log verbosity: debug, info, warn, error. |
info |
LOG_FORMAT |
Set to text for human-readable output instead of JSON. |
JSON |
| Endpoint | Port | Purpose |
|---|---|---|
/ |
8888 |
ExternalDNS negotiate (returns the provider media type). |
/records |
8888 |
ExternalDNS GET (list) and POST (apply changes). |
/healthz |
8888, 8080 |
Liveness — 200 OK while the process is running. |
/readyz |
8888, 8080 |
Readiness — probes UniFi, cached for READINESS_CACHE_TTL. |
/metrics |
8080 |
Prometheus metrics. |
/debug/pprof/ |
8080 |
Go pprof endpoints (only when PPROF_ENABLED=true). |
/healthz and /readyz are served on both ports so Kubernetes probes can target the webhook port directly without exposing a second container port through the chart.
Migrating to the UniFi Network 10.3.58 Integration API introduced breaking changes:
| Setting | Change |
|---|---|
UNIFI_EXTERNAL_CONTROLLER |
Removed. Point UNIFI_HOST at the controller directly, or use the cloud connector for remote consoles. |
DOMAIN_FILTER, EXCLUDE_DOMAIN_FILTER, REGEXP_DOMAIN_FILTER, REGEXP_DOMAIN_FILTER_EXCLUSION |
Removed. Configure --domain-filter on the ExternalDNS controller instead (see Domain filtering). |
LOG_FORMAT=test |
Renamed to LOG_FORMAT=text. |
| API endpoint | Moved from the undocumented /proxy/network/v2/api/site/{site}/static-dns/* to the official /proxy/network/integration/v1/sites/{siteId}/dns/policies/* (requires Network 10.3.58+). |
Thanks to everyone in the Home Operations Discord community.