Small, dependency-light TypeScript DDNS daemon for Cloudflare. Keeps your DNS records pointed at your home network's current public IP — runs as a single Docker container, no orchestration framework needed.
- Why
- Features
- Architecture
- Health & Status Endpoints
- Getting a Cloudflare API Token
- Deployment
- Configuration
Typical use case: a homelab server behind a router where the ISP-assigned public address changes periodically (your public IP address). Run this container on a host inside that network — it detects the egress IP via well-known services and pushes the value into Cloudflare via the API.
Effectively turning any domain you have in Cloudflare into your own DynDNS.
- ⚙️ Native Node 20 + TypeScript, no orchestration framework
- 🎯 One container, one purpose: read public IP → compare to configured records → push updates when needed
- 🔁 Multiple IPv4 / IPv6 fallback services, so one web check failing doesn't drop your DNS
- 📋 Structured JSON logs on stdout (pino) — friendly for Loki/Promtail
- ❤️ Tiny HTTP health endpoint (
/health) for DockerHEALTHCHECK, plus/statusfor the last sync result - 🛑 Graceful shutdown on
SIGTERM(Proxmox / Docker stop works cleanly) - 🖥️ Configured via environment variables — or, easier, via the built-in TUI (see Configuration)
+------------------+ fetch +-----------------+
| cloudflare-ddns | -----------------> | IP services |
| (container) | | ipify/icanhaz..|
+---------+--------+ +-----------------+
|
| PUT /zones/:id/dns_records/:id (only on change)
v
+------+----------------+
| Cloudflare API v4 |
+-----------------------+
As soon as the daemon starts, it spins up a tiny built-in HTTP server on port 8080 (bound to 0.0.0.0, reachable from the Docker host). Nothing extra to launch — it's tied to the daemon's lifecycle and shuts down with it.
| Method | Path | Returns |
|---|---|---|
GET |
/health |
200 ok (plain text) — process is alive |
GET |
/healthz |
same as /health (Kubernetes-style alias) |
GET |
/status |
200 JSON — startedAt, lastSyncAt, lastSyncOk, lastError |
Use it for:
- Docker
HEALTHCHECK/ Kubernetes liveness probe →GET /health - Monitoring / dashboards / alerting (Uptime Kuma, Grafana, custom scripts) →
GET /status
Running behind a reverse proxy? Just forward
/healthand/statusto container port8080.
-
Open your tokens page
-
Click Create Token → pick All Domains or Specific Domains (if Specific, select every domain the DDNS should update)
-
Grant these permissions under DNS & Zones:
Scope Permission DNS Read + Write Zone Read
A prebuilt, multi-arch image (amd64 + arm64) is published on Docker Hub: theminemat/cloudflare-ddns
.env:
CF_API_TOKEN=your-cloudflare-api-tokendocker-compose.yml:
services:
cloudflare-ddns:
image: theminemat/cloudflare-ddns:latest
container_name: cloudflare-ddns
restart: unless-stopped
environment:
CF_API_TOKEN: "your-cloudflare-api-token"
volumes:
- ./data:/app/data
ports:
- "8080:8080" # optional: /health + /status from host
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10sdocker compose up -d💡 Records & runtime settings are easiest to configure via the built-in TUI:
docker exec -it cloudflare-ddns node dist/cli/manage.js— or pass aRECORDSenv var, see Configuration.
Option B — plain docker run
docker run -d \
--name cloudflare-ddns \
--restart unless-stopped \
--env-file .env \
-v ./data:/app/data \
-p 8080:8080 \
--health-cmd="wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=10s \
theminemat/cloudflare-ddns:latestOption C — build it yourself
docker compose -f docker-compose.yml up -d --buildThe included docker-compose.yml already has a build section pointing at the root Dockerfile.
Two ways to configure cloudflare-ddns: the interactive TUI (manages both records and runtime settings), or env-only with no JSON files.
The TUI is a small interactive menu that runs directly in your terminal — instead of typing long cloudflare-ddns --set-records=... commands or hand-editing JSON files, you get menus, prompts with checkmarks and lists, and arrow-key/type your way through it. No browser, no extra dependencies.
One command configures your records AND the four runtime knobs (UPDATE_INTERVAL_MS, IP_TIMEOUT_MS, LOG_LEVEL, IPV6_ENABLED):
# locally (after `npm run build`):
npm run manage
# inside Docker (runs against the running container):
docker exec -it cloudflare-ddns npm run manage| Setting | Persisted to | Takes effect |
|---|---|---|
| Records | ./data/records.json |
Hot-reloaded next tick, no restart needed |
| Runtime settings | ./data/settings.json |
Needs docker compose restart cloudflare-ddns |
Option B — env-only (no files)
If you'd rather not ship any JSON file, drive everything from environment variables — useful for bare-bones setups, CI, or declarative config.
# Required:
CF_API_TOKEN=your-cloudflare-api-token
# Records - pick ONE of the two:
RECORDS=[{"name":"home.example.com","zone":"example.com","type":"A","ttl":1,"proxied":false}]
# ...or point at a JSON file on disk:
RECORDS_FILE=/etc/cloudflare-ddns/records.json
# Optional runtime knobs (defaults shown):
UPDATE_INTERVAL_MS=300000 # re-check the public IP every 5 minutes
IP_TIMEOUT_MS=10000 # per-IP-service timeout in ms
LOG_LEVEL=info # pino: fatal|error|warn|info|debug|trace|silent
IPV6_ENABLED=false # also detect + push AAAA recordsThe value of RECORDS (or the JSON file RECORDS_FILE points at) must be either a bare array of records or an { "records": [ ... ] } envelope:
[
{ "name": "home.example.com", "zone": "example.com",
"type": "A", "ttl": 1, "proxied": false },
{ "name": "v6.example.com", "zone": "example.com",
"type": "AAAA", "ttl": 1, "proxied": false }
]Field reference per record:
| field | meaning | required |
|---|---|---|
name |
full record name (FQDN) | yes |
zone |
apex domain the record belongs to | yes |
type |
A (IPv4) or AAAA (IPv6) |
yes |
ttl |
seconds, 1 = "automatic" (default: 1) |
no |
proxied |
orange cloud (default: false) |
no |
comment |
free-form string, visible in dashboard | no |
Same rule for every value the daemon reads:
- env var (always wins)
- JSON file (
records.json/settings.json) - compiled-in default
- Runtime knobs are independent. Each of
UPDATE_INTERVAL_MS,IP_TIMEOUT_MS,LOG_LEVEL,IPV6_ENABLEDis checked separately — if its env var is set, that knob shows as🔒 locked. Other knobs stay editable and write tosettings.json. - Records are one unit: if either
RECORDSorRECORDS_FILEis set, the whole list is locked. Add/Edit/Delete/Save disappear from the TUI menu and the current env value is shown read-only, since the daemon would ignore anything saved torecords.jsonwhile env is set.
To unlock either, unset the relevant env var(s), restart the daemon, and relaunch the manager.
npm run status
# or, inside Docker:
docker compose exec cloudflare-ddns node dist/index.js statusPrints the active records source, the four runtime knobs with their origin, the API-token status, a Cloudflare auth check, and a fresh public-IP probe. This is a one-shot tool — it doesn't peek into the running daemon, it just reads the same sources (files + env) and Cloudflare on demand.