Sign inSign up

dmanavi/ddns

By dmanavi

•Updated about 1 month ago

Self-hosted dynamic DNS server: DigitalOcean, Vultr, Cloudflare, Azure DNS, Route53. HTTP + CLI.

Image
Networking
Integration & delivery
Developer tools
0

1.5K

dmanavi/ddns repository overview

⁠ddns

GitHub CI Coverage Packagist Version Packagist Downloads Docker Pulls Docker Image Size PHP Version License

A self-hosted dynamic DNS server. It wraps several DNS provider APIs behind one simplified interface and exposes that through two interchangeable front-ends:

  • HTTP — a router, cron job, or container calls an endpoint and the server works out the caller's address and updates the record.
  • CLI — the same operation locally, either one-shot or as a polling loop.

Both go through the same code path, so behaviour never diverges between them.

$ curl -H "Authorization: Bearer $TOKEN" https://ddns.example.com/v1/hosts/home/update
{
    "host": "home",
    "fqdn": "home.example.com",
    "status": "updated",
    "changed": true,
    "records": [
        { "type": "A", "status": "updated", "ip": "203.0.113.99", "previous": "203.0.113.77" }
    ],
    "client_ip": "203.0.113.99"
}

⁠Why

Most DDNS clients are tied to one provider and one delivery mechanism. This one separates the three concerns:

  • Providers are thin. A driver implements three methods — find, create, update. Nothing else.
  • The logic lives once. Deciding between create and update, refusing to write when nothing changed, TTL handling, dual-stack hosts: all of it sits in a single DdnsUpdater, shared by HTTP and CLI.
  • State is a file. No database. A YAML file with ${ENV_VAR} placeholders is the whole configuration, so it can be version-controlled and the secrets injected at runtime.

The unchanged short-circuit matters most in practice: a record already pointing at the right address costs one read and zero writes, which is what makes a 60-second poll interval safe against provider rate limits.

⁠Providers

DriverStatusNotes
digitaloceanAvailableDomain Records API, fully paginated
vultrAvailableDNS API v2, cursor pagination
cloudflareAvailableAPI v4; zone IDs resolved once and cached
azurednsAvailableAzure public DNS zones, via the management REST API
azureprivatednsAvailableAzure Private DNS zones
route53AvailableAWS SDK for PHP; supports the full AWS credential chain
$ ddns providers:list

Adding one is a single class plus a factory — see Adding a provider⁠.

⁠Quick start

⁠Docker

Two stacks, one image:

FilePurpose
compose.yamlProduction — hardened, restart policies, no source mounts
compose.dev.yamlDevelopment — source bind-mounted, dev dependencies, toolchain

Requires Compose v2.24 or newer (for env_file: required:), invoked as docker compose. The standalone docker-compose v1 reached end of life in July 2023 and cannot parse these files.

On the file names. compose.yaml is the canonical name in the Compose Specification⁠; Docker's docs list it as preferred and treat docker-compose.yml as supported only "for backwards compatibility of earlier versions"1⁠. Compose finds compose.yaml by default, which is why the production commands below need no -f.

Production:

cp config/ddns.example.yaml config/ddns.yaml   # edit: zone, record, provider
cp .env.example .env                          # edit: API token, host token

docker compose up -d                    # HTTP endpoint
docker compose --profile watcher up -d  # or poll from inside your network

The server binds to 127.0.0.1:8080 by default, on the assumption that a reverse proxy terminates TLS in front of it. Set DDNS_HTTP_BIND=0.0.0.0:8080 to expose it directly. The container runs as uid 1000 with a read-only root filesystem and all capabilities dropped.

Development: ./bin/ddns config:init --sample once, then docker compose -f compose.dev.yaml up — see Running it locally⁠.

The two stacks use different Compose project names (ddns and ddns-dev), so a dev stack can never collide with a production one on the same host.

dev is the last stage in the Dockerfile, so a plain docker build . produces the development image. Pass --target runtime for production.

Without cloning the repository: the same image is published to both ghcr.io/dmanavi/ddns and dmanavi/ddns on Docker Hub, tagged with the full version (for example 1.20260818.2157), major.minor (the release date), major and latest. Fetch just the two files a container needs and run it directly:

mkdir ddns && cd ddns
curl -O https://raw.githubusercontent.com/DManavi/ddns/main/config/ddns.example.yaml
mv ddns.example.yaml ddns.yaml   # edit: zone, record, provider
curl -O https://raw.githubusercontent.com/DManavi/ddns/main/.env.example
mv .env.example .env             # edit: API token, host token

docker run -d --name ddns \
  --env-file .env \
  -v "$(pwd)/ddns.yaml:/config/ddns.yaml:ro" \
  -p 127.0.0.1:8080:8080 \
  ghcr.io/dmanavi/ddns:latest
# or dmanavi/ddns:latest — same image, the other registry

Prefer compose.yaml for anything longer-lived than a quick check: it adds the read-only root filesystem, dropped capabilities and restart policy this plain docker run does not. It still needs cloning for the file itself, but not for an image build — point it at the published image instead of building:

DDNS_IMAGE=ghcr.io/dmanavi/ddns:latest docker compose up -d

The rest of the documentation - configuration reference, HTTP API, CLI, provider setup, security notes and more - is in the full README on GitHub: https://github.com/DManavi/ddns#readme⁠

⁠Footnotes

  1. How Compose works — The Compose file⁠ ↩⁠

Tag summary

Content type

Image

Digest

sha256:01fe78527…

Size

37.7 MB

Last updated

about 1 month ago

docker pull dmanavi/ddns