Leia em português.
A Go controller that watches Docker containers and keeps a cloud DNS provider in sync with the records declared in their labels. Inspired by external-dns, but for Docker instead of Kubernetes.
Add a couple of labels to any service and the matching DNS record appears; stop the container and it is cleaned up:
services:
app:
image: nginx
labels:
externaldns.hostname: app.example.com
externaldns.target: "203.0.113.10"The provider is chosen at runtime (--provider / PROVIDER), so the same
binary targets Cloudflare today and any backend added later — the controller
core never changes.
Docker labels ──▶ Source ──▶ Plan ──▶ Registry ──▶ Provider ──▶ Cloud DNS
(externaldns (diff) (ownership) (Cloudflare,
+ Traefik) Pi-hole)
- Source — lists running containers and builds the desired set of DNS
endpoints from their labels: the native
externaldns.*scheme and, optionally, Traefik router rules. It also watches Docker events (start,stop,die, ...) with debouncing, plus a periodic resync (default 5 min). - Plan — diffs desired endpoints against what the provider already holds and produces the minimal create/update/delete set.
- Registry — decides which records the controller is allowed to change. The
default
txtregistry writes a companion TXT ownership record per managed record (external-dns style), so it only ever touches records it owns — manual records and records owned by another instance are reported as conflicts and left alone. Providers that cannot store TXT (e.g. Pi-hole) use thenoopregistry instead. See Registries. - Provider — translates the plan into API calls. Cloudflare and Pi-hole ship today; adding another means implementing a small interface (see Adding a provider).
Every stage is pluggable, so a new source (Podman), registry or provider drops in without touching the others.
| Label | Required | Description |
|---|---|---|
externaldns.hostname |
yes | The DNS name, e.g. app.example.com |
externaldns.type |
no | A (default), AAAA, CNAME or TXT |
externaldns.target |
yes* | Record content. Comma-separate for multiple targets (e.g. two A records). *optional for A/AAAA when --default-target is set |
externaldns.ttl |
no | TTL in seconds; omit for the provider default |
externaldns.proxied |
no | Cloudflare only: true/false to route through its proxy |
externaldns.exclude |
no | true opts the whole container out of DNS management (native and Traefik) |
Use an index segment:
labels:
externaldns.0.hostname: app.example.com
externaldns.0.target: "203.0.113.10"
externaldns.1.hostname: www.example.com
externaldns.1.type: CNAME
externaldns.1.target: app.example.comIf you already label your services for Traefik, you get
DNS for free — no externaldns.* labels needed. The controller reads the
hostnames from Traefik router rules (enabled by default; disable with
--traefik=false):
labels:
traefik.enable: "true"
traefik.http.routers.app.rule: Host(`app.example.com`)-
Both
Host(...)(HTTP routers) andHostSNI(...)(TCP routers) are read;Host(a,b)andHost(a) || Host(b)yield multiple records.HostRegexp(...)is ignored — a regex has no concrete name. -
traefik.enable: "false"opts a container out, mirroring Traefik itself. -
To skip DNS for a service while keeping Traefik routing, add
externaldns.exclude: "true"— the controller ignores the container (and removes any record it previously owned for it). -
Traefik labels say what the hostname is, not where it points. The record is therefore an
Arecord to--default-target(typically the host IP where Traefik listens). Set it globally:--default-target 203.0.113.10 # or DEFAULT_TARGET=203.0.113.10 -
To override the record shape for one container, add the non-indexed
externaldns.*keys alongside the Traefik labels — e.g. point every Traefik host on this container at a proxied CNAME:labels: traefik.http.routers.app.rule: Host(`app.example.com`) externaldns.type: CNAME externaldns.target: tunnel.example.net externaldns.proxied: "true"
When a container carries both a native externaldns.hostname record and a
Traefik rule for the same name, the explicit native declaration wins.
Every flag has an environment-variable equivalent. Flags win over the environment, which wins over the defaults.
| Flag | Env | Default | Description |
|---|---|---|---|
--provider |
PROVIDER |
cloudflare |
DNS backend to manage (cloudflare, pihole) |
--registry |
REGISTRY |
(per-provider) | Ownership tracking: txt or noop |
--label-prefix |
LABEL_PREFIX |
externaldns |
Container label namespace |
--owner-id |
OWNER_ID |
machine hostname | Identity written to ownership TXT records |
--txt-prefix |
TXT_PREFIX |
externaldns- |
Prefix for ownership TXT record names |
--domain-filter |
DOMAIN_FILTER |
(all visible) | Comma-separated domains/zones to manage |
--default-target |
DEFAULT_TARGET |
(none) | Fallback content for A/AAAA records without a target |
--traefik |
ENABLE_TRAEFIK |
true |
Derive records from Traefik router Host(...) labels |
--resync-interval |
RESYNC_INTERVAL |
5m |
Full reconcile cadence |
--debounce-interval |
DEBOUNCE_INTERVAL |
2s |
Event batching window |
--dry-run |
DRY_RUN |
false |
Log changes without applying them |
--log-level |
LOG_LEVEL |
info |
info or debug |
--cf-api-token |
CLOUDFLARE_API_TOKEN |
— | Cloudflare token (Zone > DNS > Edit) |
--pihole-url |
PIHOLE_URL |
— | Pi-hole base URL, e.g. http://pi.hole |
--pihole-password |
PIHOLE_PASSWORD |
— | Pi-hole web/API password |
--pihole-tls-insecure |
PIHOLE_TLS_INSECURE |
false |
Skip TLS verification (self-signed certs) |
Create an API token with Zone → DNS → Edit on the zones you want to manage.
No account-level permission is needed. Scope the token to specific zones and the
controller stays within them; use --domain-filter to narrow further.
Manages Pi-hole v6 local DNS (Settings → Local DNS records), i.e. the
<ip> <hostname> entries — so A and AAAA records. The controller
authenticates with the web/API password (POST /api/auth), reuses the returned
session id via the X-FTL-SID header, and refreshes it automatically when it
expires. Record types Pi-hole cannot store as hosts (CNAME, TXT) are skipped
with a warning.
PIHOLE_URL=http://pi.hole PIHOLE_PASSWORD=... \
./externaldns-controller --provider pihole --domain-filter home.example.com --dry-runOwnership on Pi-hole. Pi-hole has nowhere to store the TXT ownership records the default registry uses, so the Pi-hole provider defaults to the
noopregistry: it manages every local DNS entry in scope. Always set--domain-filterso it only touches your managed names — otherwise hand-made entries outside the desired set would be removed.
How the controller decides which records it may modify or delete:
txt(default for Cloudflare) — writes a companion TXT record per managed record (external-dns style). Only records it created are ever changed; manual records and records owned by another instance are left alone.noop(default for Pi-hole) — no ownership marker; the controller manages everything the provider reports within scope. Pair it with--domain-filter.
Override the default with --registry.
With Docker Compose — docker-compose.yml for Cloudflare,
docker-compose.pihole.yml for Pi-hole:
export CLOUDFLARE_API_TOKEN=...
docker compose up -dOr standalone:
go build -o externaldns-controller ./cmd/externaldns-controller
CLOUDFLARE_API_TOKEN=... ./externaldns-controller --dry-runStart with --dry-run to see exactly which records would be created before
letting the controller write anything.
-
Create
internal/provider/<name>/implementing the two-methodprovider.Providerinterface:Records(ctx) ([]*endpoint.Endpoint, error) ApplyChanges(ctx, *plan.Changes) error
-
Register it in the factory switch in
cmd/externaldns-controller/main.go. If the backend cannot store TXT records, default it to thenoopregistry inConfig.RegistryKind(as Pi-hole does).
Nothing else — sources, planning and registries are provider-agnostic. Podman is a natural next source (it speaks the Docker API), and Route53 / DigitalOcean are natural next providers.
go test ./... # unit tests
go test -tags integration ./... # requires a running Docker daemon
go vet ./... && gofmt -l .