v2.0.3 · self-hosted dynamic DNS

DNS records that
stay in sync.

OzDDNS discovers your address, reads the live record, and writes only when the two disagree. The panel, REST API, CLI, scheduler, and SQLite storage all ship in one Go binary.

Binds to 127.0.0.1 by default — HTTPS stays at your reverse proxy.

  • Single binary
  • Go 1.26+
  • SQLite
  • Docker
ozddns — docker
# one volume, one container, one process
docker volume create ozddns-data

docker run -d --name ozddns \
  --restart unless-stopped \
  -p 127.0.0.1:9876:9876 \
  -v ozddns-data:/data \
  ghcr.io/echoface/ozddns:latest \
  --db /data/ozddns.db \
  serve --listen 0.0.0.0:9876

# panel → http://127.0.0.1:9876
dry-run preview update
task
home.example.com · A
source
https://api.ipify.org
current
198.51.100.7
desired
203.0.113.42

Matching values skip the write entirely.

Read, diff, then write.

Every run follows the same path. Nothing is written on a guess, and every execution is recorded.

  1. 01

    Discover

    Resolve the current address from an HTTP endpoint, a local interface, a shell command, or a static value.

  2. 02

    Compare

    Read the DNS record from the provider and compute the difference between desired and current.

  3. 03

    Write

    Update only when the target differs, then keep the run in rolling history.

Built for the boring part of DNS.

The interesting part of dynamic DNS is deciding what to write. Everything else should be predictable.

Reconciliation first

Reads the live record, computes the diff, and writes only when the desired state differs.

Safe previews

Dry-run a task from the panel, the CLI, or the API and inspect the exact change before it lands.

Provider accounts

Configure multiple accounts independently and reuse each one across any number of tasks.

Four address sources

HTTP lookup, local interface, shell command, or a static IPv4/IPv6 value — chosen per task.

One app layer

Panel, CLI, and API call the same use cases, so behavior cannot drift between surfaces.

Scheduling built in

Per-task intervals, optional jitter, concurrent workers, pause/resume, run-all, and rolling history.

Ops included

Webhook notifications, configuration export/import, health checks, and JSON output from the CLI.

Single binary

The production Vue panel is embedded with go:embed. Ship one file; there is no runtime to install.

Localized panel

English, Simplified Chinese, and Japanese, with light, dark, and system themes.

One engine, nine backends.

Each adapter has its own credential form, zone lookup, and record API. The reconciliation engine stays provider-independent.

  • CF Cloudflare cloudflare
  • AL Alibaba Cloud DNS alidns
  • DP DNSPod (Tencent) dnspod
  • DK DuckDNS duckdns
  • NC Namecheap namecheap
  • GD GoDaddy godaddy
  • dS deSEC desec
  • VC Vercel DNS vercel
  • + Custom HTTP endpoint custom

IPv4 A and IPv6 AAAA targets where the provider supports them. Cloudflare proxy state is available on Cloudflare records.

From zero to scheduled, in four steps.

The panel is the shortest path. The same workflow is available from the CLI and the REST API.

  1. 1

    Connect a provider

    Add credentials for one account and validate them against the provider.

  2. 2

    Add an address source

    Choose HTTP, interface, command, or a static address, and pick the IP family.

  3. 3

    Create a task

    Point the source at DNS targets such as home.example.com:A:300.

  4. 4

    Preview, then schedule

    Dry-run the diff, run it once, and leave the interval enabled.

The same flow from the CLI
./bin/ozddns --db ./ozddns.db \
  job run --dry-run <job-id>
./bin/ozddns --db ./ozddns.db \
  job run <job-id>

Provider, source, and task creation all have CLI equivalents. Use --json for machine-readable output.

Run it wherever a container fits.

One process owns the HTTP server, scheduler, reconciliation, and retention. systemd, Docker, or Compose only supervise that process.

docker
docker volume create ozddns-data

docker run -d \
  --name ozddns \
  --restart unless-stopped \
  -p 127.0.0.1:9876:9876 \
  -v ozddns-data:/data \
  ghcr.io/echoface/ozddns:latest \
  --db /data/ozddns.db \
  serve --listen 0.0.0.0:9876

The published port stays on loopback. Expose it only behind a trusted reverse proxy. Use a version tag such as 2.0.3 for reproducible deploys.

Safe defaults, where they matter.

OzDDNS targets a trusted single-user machine. It is explicit about where that boundary ends.

  • Loopback by default

    The daemon binds 127.0.0.1:9876. An open panel reachable beyond loopback is flagged in the CLI and the UI.

  • Opt-in two-factor

    Password → TOTP or a single-use recovery code → an opaque server-side session. Enrollment starts from loopback.

  • No token in the browser

    Only an HttpOnly, SameSite=Lax cookie is sent to the browser; the database stores token hashes.

  • Masked by default

    Provider credentials are masked in normal panel, CLI, and API reads. Configuration exports do contain them — protect the files.

  • Proxy-aware, not proxy-trusting

    trustProxy stays off until every request arrives through a proxy that overwrites X-Forwarded-Proto and X-Forwarded-For.

  • HTTPS at the edge

    OzDDNS does not terminate TLS. Put nginx or another trusted reverse proxy in front before exposing it.

Static HTML. Zero build. Free to host.

No framework, no tracking, no external requests. Drop the site/ directory on Vercel or Cloudflare Pages and point a domain at it.

Deployment guide