🏠 Defdo DDNS - Simple Dynamic DNS for Home Labs

Keep your home server accessible even when your IP address changes!

Defdo DDNS automatically updates your Cloudflare DNS records when your home internet IP address changes. Perfect for home labs, self-hosted services, and remote access.

🤔 What Problem Does This Solve?

The Problem: Your home internet provider gives you a dynamic IP address that changes periodically. This breaks access to your home servers, security cameras, or self-hosted applications.

The Solution: Defdo DDNS monitors your IP address and automatically updates your domain's DNS records in Cloudflare whenever it changes.

✨ Key Features

🚀 Quick Start

Prerequisites

  1. Domain managed by Cloudflare (free account works fine)
  2. Cloudflare API Token with DNS edit permissions
  3. Docker or Podman installed on your system

Step 1: Get Your Cloudflare API Token

  1. Go to Cloudflare Dashboard
  2. Click "Create Token"
  3. Use the "Custom token" template
  4. Set permissions:
    • ZoneZoneRead
    • ZoneDNSEdit
  5. Set zone resources to include your domain
  6. Copy the generated token

Step 2: Configure Your Domains

You can configure monitored hostnames in two formats.

Legacy string format (CLOUDFLARE_DOMAIN_MAPPINGS):

domain1.com:subdomain1,subdomain2;domain2.com:api,blog

Examples:

JSON format (CLOUDFLARE_A_RECORDS_JSON, recommended for automation):

{"example.com":["www","api"],"defdo.in":[]}

IPv6 JSON format (CLOUDFLARE_AAAA_RECORDS_JSON, optional):

{"example.com":["www"],"ipv6-only.net":["app"]}

Or array form:

[{"domain":"example.com","subdomains":["www","api"]},{"domain":"defdo.in","subdomains":[]}]

Priority:

Step 3: Run with Docker

docker run -d \
--name defdo-ddns \
--restart unless-stopped \
-e CLOUDFLARE_API_TOKEN="your_token_here" \
-e CLOUDFLARE_A_RECORDS_JSON='{"example.com":["www","api"]}' \
-e CLOUDFLARE_AAAA_RECORDS_JSON='{"example.com":["www"]}' \
-e AUTO_CREATE_DNS_RECORDS="true" \
-e CLOUDFLARE_PROXY_A_RECORDS="true" \
-e CLOUDFLARE_CNAME_RECORDS_JSON='[{"name":"*","target":"@","proxied":true}]' \
paridin/defdo_ddns

Step 4: Verify It's Working

Check the logs to see if it's working:

docker logs defdo-ddns

You should see messages like:

Executing checkup...
Processing domain: example.com
Success - www.example.com DNS record updated to IP 203.0.113.1
Checkup completed

⚙️ Configuration Options

Environment VariableRequiredDefaultDescription
CLOUDFLARE_API_TOKEN✅ Yes-Your Cloudflare API token
CLOUDFLARE_DOMAIN_MAPPINGS✅ Yes*-Legacy domain mapping string (domain.com:sub1,sub2;...)
CLOUDFLARE_A_RECORDS_JSON❌ No""JSON A record mappings. When valid, overrides CLOUDFLARE_DOMAIN_MAPPINGS
CLOUDFLARE_AAAA_RECORDS_JSON❌ No""JSON AAAA record mappings. Managed independently from A mappings
AUTO_CREATE_DNS_RECORDS❌ NofalseAuto-create missing DNS records
CLOUDFLARE_PROXY_A_RECORDS❌ NofalseForce Cloudflare proxy mode (proxied=true) for A/AAAA records
CLOUDFLARE_PROXY_EXCLUDE❌ No""Comma/space-separated host patterns to keep DNS only even when proxy mode is enabled. Supports exact hosts and wildcard suffixes (*.idp-dev.example.com)
DDNS_RECORD_SNAPSHOT_PATH❌ No""Portable runtime snapshot file path. Used as the preferred bootstrap source and persistence target
DDNS_RECORD_INIT_PATH❌ No""Optional init snapshot path used when no runtime snapshot exists yet
DDNS_ALLOW_EMPTY_RECORDS❌ NofalseAllow booting the record store with an empty runtime state (defaults to true in test)
DDNS_PERSIST_RUNTIME_RECORDS❌ NofalsePersist runtime record changes back to the snapshot path
CLOUDFLARE_CNAME_RECORDS_JSON❌ No[]Legacy seed JSON for managed CNAME records (name, target, optional proxied, ttl, domain)
DDNS_ENABLE_MONITOR❌ Notrue**Enable/disable background monitor process
DDNS_REFETCH_EVERY_MS❌ No300000Monitor interval in milliseconds
DDNS_API_ENABLED❌ NofalseEnable embedded HTTP API (Bandit)
DDNS_API_PORT❌ No4050HTTP API listen port
DDNS_API_TOKEN⚠️ Conditional***-Global API token fallback (single-client mode)
DDNS_API_CLIENTS_JSON⚠️ Conditional***[]Multi-tenant-light clients (id, token, allowed_base_domains)
DDNS_API_ALLOW_RUNTIME_CLIENTS❌ NofalseAllow API startup without token/clients (deny-all until runtime injection)
DDNS_API_DEFAULT_TARGET❌ No@Default CNAME target used by API upsert
DDNS_API_DEFAULT_PROXIED❌ NotrueDefault proxied mode used by API upsert

* Required unless CLOUDFLARE_A_RECORDS_JSON or CLOUDFLARE_AAAA_RECORDS_JSON is provided. ** In test environment the default is false to avoid background network checks during test runs. *** When DDNS_API_ENABLED=true, configure at least one auth mode:

Runtime Record Store

Runtime DDNS records live in ETS. The store bootstraps in this order:

  1. DDNS_RECORD_SNAPSHOT_PATH
  2. DDNS_RECORD_INIT_PATH
  3. CLOUDFLARE_CNAME_RECORDS_JSON legacy seed
  4. empty state only when DDNS_ALLOW_EMPTY_RECORDS=true

Use the runtime facade when you need to inspect or export state from a release, container, or embedded deployment:

Defdo.DDNS.RecordStore.status()
Defdo.DDNS.RecordStore.list_records()
Defdo.DDNS.RecordStore.export_snapshot()
Defdo.DDNS.RecordStore.write_snapshot("/mnt/data/defdo-ddns/records.json")
Defdo.DDNS.RecordStore.persist()
Defdo.DDNS.RecordStore.reload()

Operational notes:

Example:

-e CLOUDFLARE_CNAME_RECORDS_JSON='[
{"name":"*","target":"@","proxied":true},
{"name":"join","target":"@","proxied":true,"ttl":1,"domain":"defdo.in"},
{"name":"www","target":"@","proxied":true}
]'

Rules:

Optional HTTP API (Bandit)

This project can expose a lightweight HTTP API using Bandit. Enable it with:

-e DDNS_API_ENABLED=true \
-e DDNS_API_PORT=4050 \
-e DDNS_API_TOKEN="replace-with-strong-token"

Multi-tenant-light mode with client credentials:

-e DDNS_API_ENABLED=true \
-e DDNS_API_PORT=4050 \
-e DDNS_API_CLIENTS_JSON='[
{"id":"tenant-a","token":"tenant-a-secret","allowed_base_domains":["defdo.in"]},
{"id":"tenant-b","token":"tenant-b-secret","allowed_base_domains":["defdo.dev","example.com"]}
]'

Endpoints:

Auth headers:

Example request:

curl -X POST "http://localhost:4050/v1/dns/upsert" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DDNS_API_TOKEN}" \
-d '{"fqdn":"acme-idp.defdo.in","base_domain":"defdo.in","target":"@","proxied":true}'

Multi-tenant-light request:

curl -X POST "http://localhost:4050/v1/dns/upsert" \
-H "Content-Type: application/json" \
-H "x-client-id: tenant-a" \
-H "x-api-token: tenant-a-secret" \
-d '{"fqdn":"acme-idp.defdo.in","base_domain":"defdo.in","target":"@","proxied":false}'

defdo_auth Integration (multi-tenant-light)

defdo_ddns can stay stateless and only enforce technical authorization + DNS rules. defdo_auth (or another upstream service) should own tenant/business data and send authorized calls.

Recommended split:

  1. defdo_auth stores tenant/client data and decides who can upsert which domain.
  2. defdo_auth calls defdo_ddns HTTP API with x-client-id, token, and DNS payload.
  3. defdo_ddns validates token + allowed base domain and executes DNS upsert idempotently.

If defdo_auth embeds this library in the same BEAM VM, you can inject credentials in memory (ETS-backed):

clients = [
%{
"id" => "tenant-a",
"token" => "tenant-a-secret",
"allowed_base_domains" => ["defdo.in"]
}
]
:ok = Defdo.DDNS.set_api_clients(clients)
safe_view = Defdo.DDNS.api_clients() # token redacted
raw_view = Defdo.DDNS.api_clients(redact: false) # trusted local debugging only

To boot in deny-all mode until runtime injection:

-e DDNS_API_ENABLED=true \
-e DDNS_API_ALLOW_RUNTIME_CLIENTS=true

📋 Advanced Usage

For UniFi Dream Machine Pro Users

Create a startup script at /mnt/data/on_boot.d/30-defdo-ddns.sh:

#!/bin/sh
CONTAINER=defdo-ddns
if podman container exists "$CONTAINER"; then
podman start "$CONTAINER"
else
podman run -d --rm \
--net=host \
--name "$CONTAINER" \
--security-opt=no-new-privileges \
-e CLOUDFLARE_API_TOKEN="your_token_here" \
-e CLOUDFLARE_A_RECORDS_JSON='{"example.com":["www","api"]}' \
-e CLOUDFLARE_AAAA_RECORDS_JSON='{"example.com":["www"]}' \
-e AUTO_CREATE_DNS_RECORDS="true" \
-e CLOUDFLARE_PROXY_A_RECORDS="true" \
-e CLOUDFLARE_PROXY_EXCLUDE="*.idp-dev.example.com,*.iot-dev.example.com" \
-e CLOUDFLARE_CNAME_RECORDS_JSON='[{"name":"*","target":"@","proxied":true}]' \
paridin/defdo_ddns
fi

Make it executable:

chmod +x /mnt/data/on_boot.d/30-defdo-ddns.sh

Docker Compose

version: '3.8'
services:
defdo-ddns:
image: paridin/defdo_ddns
container_name: defdo-ddns
restart: unless-stopped
environment:
- CLOUDFLARE_API_TOKEN=your_token_here
- 'CLOUDFLARE_A_RECORDS_JSON={"example.com":["www","api"]}'
- 'CLOUDFLARE_AAAA_RECORDS_JSON={"example.com":["www"]}'
- AUTO_CREATE_DNS_RECORDS=true
- CLOUDFLARE_PROXY_A_RECORDS=true
- CLOUDFLARE_PROXY_EXCLUDE=*.idp-dev.example.com,*.iot-dev.example.com
- 'CLOUDFLARE_CNAME_RECORDS_JSON=[{"name":"*","target":"@","proxied":true}]'

🔧 Troubleshooting

Common Issues

❌ "DNS record not found"

❌ "CNAME not created / conflicting type"

❌ "AAAA records not updating"

❌ "Hairpin NAT / loopback issues"

Cloudflare SSL/TLS Modes (Flexible vs Full vs Full strict)

If your records are proxied by Cloudflare (orange cloud), Cloudflare becomes the TLS client to your origin (Traefik/Nginx/Caddy). Choosing the wrong SSL/TLS mode can cause confusing errors like 404 at the edge or origin mismatch.

ModeCloudflare -> OriginCertificate ValidationTypical Impact
FlexibleHTTP (:80)No TLS to originCan fail when origin only serves HTTPS (websecure) and is not recommended for production security
FullHTTPS (:443)No validationUsually works with self-signed certs, but TLS trust is weak
Full (strict)HTTPS (:443)Yes (valid cert required)Recommended. End-to-end TLS with proper trust and fewer routing surprises

Recommended for home labs with reverse proxies: Full (strict).

Orange cloud vs gray cloud (Cloudflare DNS proxy)

In Cloudflare DNS, each record has a cloud status:

In this project, CLOUDFLARE_PROXY_A_RECORDS=true means records are updated/created as orange cloud (proxied=true). Set it to false for gray cloud (DNS only).

Hairpin NAT vs Cloudflare Proxy (why behavior changes)

With CLOUDFLARE_PROXY_A_RECORDS=true:

Safe Cloudflare checklist for proxied hosts

  1. Set SSL/TLS mode to Full (strict).
  2. Keep Host header and SNI preserved unless you intentionally override them.
  3. Avoid broad Origin Rules over "all incoming requests" unless you need them.
  4. If behavior seems inconsistent after toggling proxy mode, flush local DNS cache and re-test.

❌ "Authentication failed"

❌ "Zone not found"

Getting Help

Check logs for detailed error messages:

docker logs defdo-ddns --follow

Health Status Output (GREEN / YELLOW / RED)

Each checkup now emits a domain posture line like:

[HEALTH][GREEN] domain=example.com ssl_mode=strict edge_tls=green proxied=3/3 dns_only=0 proxy_mismatch=0 hairpin_risk=low

Interpretation:

When deep proxied hostnames are detected, logs can also include:

[CERT][ACM] ... may not be covered by Cloudflare Universal SSL and can require Advanced Certificate Manager.

🛣️ Roadmap

✅ Completed

⏭️ Next

🤝 Contributing

We welcome contributions! Please feel free to:

📄 License

Licensed under Apache License 2.0. See LICENSE.md.


🇲🇽 Support Our Mission (Mexico Only)

If you love open source and want to support our work, consider joining our developer-focused mobile service in Mexico: defdo community. Help us grow while getting great mobile service!