Skip to content

Repository files navigation

cloudflare-ddns banner

cloudflare-ddns

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.

Docker Pulls Docker Image Size Docker Version Node TypeScript Multi-Arch


Table of Contents


Why

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.

Features

  • ⚙️ 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 Docker HEALTHCHECK, plus /status for 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)

Architecture

+------------------+        fetch        +-----------------+
|  cloudflare-ddns |  -----------------> |  IP services    |
|   (container)    |                     |  ipify/icanhaz..|
+---------+--------+                     +-----------------+
          |
          |   PUT /zones/:id/dns_records/:id   (only on change)
          v
   +------+----------------+
   |   Cloudflare API v4   |
   +-----------------------+

Health & Status Endpoints

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 /health and /status to container port 8080.

Getting a Cloudflare API Token

  1. Open your tokens page

  2. Click Create Token → pick All Domains or Specific Domains (if Specific, select every domain the DDNS should update)

  3. Grant these permissions under DNS & Zones:

    Scope Permission
    DNS Read + Write
    Zone Read

Deployment

A prebuilt, multi-arch image (amd64 + arm64) is published on Docker Hub: theminemat/cloudflare-ddns

Option A — docker compose (recommended)

.env:

CF_API_TOKEN=your-cloudflare-api-token

docker-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: 10s
docker 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 a RECORDS env 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:latest
Option C — build it yourself
docker compose -f docker-compose.yml up -d --build

The included docker-compose.yml already has a build section pointing at the root Dockerfile.

Configuration

Two ways to configure cloudflare-ddns: the interactive TUI (manages both records and runtime settings), or env-only with no JSON files.

Option A — the interactive TUI (recommended)

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 records

The 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

Precedence — and why some TUI entries are 🔒 locked

Same rule for every value the daemon reads:

  1. env var (always wins)
  2. JSON file (records.json / settings.json)
  3. compiled-in default
  • Runtime knobs are independent. Each of UPDATE_INTERVAL_MS, IP_TIMEOUT_MS, LOG_LEVEL, IPV6_ENABLED is checked separately — if its env var is set, that knob shows as 🔒 locked. Other knobs stay editable and write to settings.json.
  • Records are one unit: if either RECORDS or RECORDS_FILE is 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 to records.json while env is set.

To unlock either, unset the relevant env var(s), restart the daemon, and relaunch the manager.

Snapshot of effective configuration

npm run status
# or, inside Docker:
docker compose exec cloudflare-ddns node dist/index.js status

Prints 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.

About

The simplest DDNS client to set up, turning your Cloudflare DNS into a DynDNS service.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages