Operations & Scale
Fleet Operations with DynIP
A single homelab router with DDNS is a one-line problem — paste the snippet, done. Hundreds or thousands of devices in the field change the shape entirely. You now have to think about naming conventions that survive an operator with no context, programmatic provisioning that doesn't melt your dashboard, monitoring that tells you which units have gone dark, and (most painfully) what the public IP actually is for a router sitting on a carrier's CGNAT pool or a private cellular APN where there is no public IP at all.
This guide walks through DynIP at fleet scale, with a worked example threaded through: a national postal-locker network, several thousand cellular-connected Teltonika RUTX and MikroTik 4G units, all on a carrier's private APN with no internet egress. It's a real-shaped problem — common in logistics, smart metering, transit, and industrial IoT — and the DNS layer is one of the parts that's easy to get wrong before any code is shipped.
When DynIP fits a fleet
The honest first question is: do you actually need public DNS, or do you just need a way to reach the devices? Those are different problems with different tools.
If the only thing you need is a way for your central management host to reach each device from anywhere, what you want is a tunnel — Tailscale, WireGuard, OpenVPN, Cloudflare Tunnel, or a carrier-provided overlay like Teltonika RMS. The tunnel handles connectivity, addressing, and NAT traversal in one layer; you don't necessarily need DNS at all, because the tunnel provider hands you stable names or peer addresses.
If you need a name — something stable that other services can resolve, something that survives an IP change, something a TLS certificate can be issued for — that's DNS, and DynIP is the layer that gives you names that track the devices. Common reasons fleets reach for DNS specifically:
- Other systems (a SCADA host, a partner API, a monitoring probe) need to dial into the devices by name. Bare IPs don't survive WAN changes; DNS does.
- You want a free Let's Encrypt certificate per device for management interfaces (the device's local UI, an API server it runs, a metrics endpoint). DynIP's DNS-01 issuance works even on CGNAT or private APNs — certificates bind to names, not IPs.
- You're using something like RouterOS or FortiOS where the IPsec / SD-WAN config wants peer hostnames, not addresses, so an IP change at one end doesn't ripple into a fleet-wide reconfiguration.
DynIP and a tunnel are complementary, not alternatives — see the Tailscale and FortiGate guides for the joint pattern.
Routable vs internal IPs in a fleet
Three connectivity profiles cover almost every fleet. The DynIP fit is different for each — and being clear about which one you're in saves a lot of misdirected debugging.
1. Public dual-stack (the easy case)
Each device gets its own routable IPv4 and IPv6 address on the WAN. The hostname resolves to a real public IP; inbound and outbound both work without intermediaries. Cert issuance works, monitoring probes work, partner APIs can reach devices directly. This is the picture the original DDNS providers were designed for. Fiber-connected branch offices and fixed-line residential devices tend to land here.
For DynIP this is the default. The standard /ip + /update loop just works, and you can publish both an A and an AAAA per zone without thinking about it.
2. CGNAT for IPv4, public IPv6 (the modern carrier case)
Increasingly common on mobile and consumer ISPs: IPv6 is fully public and routable, IPv4 is a CGNAT-range address (often 100.64.0.0/10) shared across thousands of customers and unreachable from the outside. The IPv6 side behaves like profile 1 — publishable, reachable, certable. The IPv4 side is publishable for record-keeping and for DNS-01 (which doesn't need the IP to be reachable, only the DNS record to be controllable), but inbound IPv4 is gone.
For fleet-wide consistency, treat IPv6 as the primary reachability path and accept that IPv4 inbound requires a tunnel layer. DynIP publishes both records and DNS-01 will issue certs valid for both; what you can do with each address differs.
3. Private cellular APN / closed network (the postal-locker case)
This is the pattern most fleet operators land on once scale matters. You buy a private APN from your mobile carrier: every device gets an address from a private range (typically 10.x.x.x), all traffic egresses through a single MPLS or VPN tunnel to your data center, and the devices have no public IP and no direct internet access at all. Security is upstream of the device; the device just talks to your network.
DynIP's role here looks different. You still want stable per-device names — field techs need to SSH into locker-247-stockholm, not remember a private IP that the carrier might rotate on roam — but the address you publish is the device's APN-internal IP, only resolvable usefully from inside the APN. The update itself still needs to reach DynIP, so the APN egress has to allow outbound HTTPS to api.dynip.dev (a single hostname; a single firewall rule on the egress).
The trick is the IP-discovery step. The default api.dynip.dev/ip would return either nothing (no internet route) or the APN egress NAT address (the wrong thing — that's the same for every device on the APN). You want each device to discover and publish its own APN address. The pattern: run a small "what's my IP" service inside the APN. nginx is enough:
server {
listen 80;
server_name whatami.lockers.internal;
location / {
default_type text/plain;
return 200 "$remote_addr\n";
}
}
Deploy that on any always-on host reachable from every device in the APN — typically the same data center the APN tunnel terminates in. Then each router's DDNS script asks whatami.lockers.internal for its IP and pushes that to api.dynip.dev/update. Worked end-to-end for a single locker:
# On router locker-247-stockholm (MikroTik example, RouterOS 7): /system script add name="dynip-update" source=" :local currentIP ([/tool fetch url=\"http://whatami.lockers.internal/\" output=user as-value]->\"data\") :local updateUrl \"https://api.dynip.dev/update?domain=locker-247-stockholm.lockers.example.com&ip=\$currentIP&key=YOUR_URL_ENCODED_KEY\" /tool fetch url=\$updateUrl keep-result=no " /system scheduler add name="dynip-schedule" interval=10m on-event="dynip-update"
What this gets you, concretely:
dig locker-247-stockholm.lockers.example.comfrom a field tech laptop on the APN VPN returns10.42.3.18(or whatever the carrier handed the SIM today). SSH and the locker's web UI work normally.- The same lookup from the public internet returns the same address — useful for monitoring (you can see the address changed) but not reachable, which is the whole security point of the private APN.
- Each device can get its own Let's Encrypt cert for its full hostname (e.g.
locker-247-stockholm.lockers.example.com) via DynIP's DNS-01 issuance — the validator only ever talks to DynIP's public nameservers, never to the device, so it works fine on a private APN. The cert binds to the name and is valid whether someone reaches the locker over the APN, over a VPN, or never at all. The trade-off is one issuance and one renewal per device.
A note on wildcards: DynIP can only issue certificates for zones it controls, and in the per-device-zone model each device's zone is its own leaf — there is no DynIP-managed lockers.example.com parent to validate a *.lockers.example.com wildcard against. Two ways to get one anyway: BYOD the parent lockers.example.com namespace to DynIP, which makes the parent itself a zone that can be wildcard-validated; or issue the wildcard out of band against whatever DNS provider hosts lockers.example.com today, then push the resulting cert to devices via your fleet-management tool. For most fleets the per-device cert path is simpler — one cert per device is fine at the scale where the device count is also the cert count.
Be honest with yourself about what this pattern can't do: the public DNS records leak the internal addressing structure to anyone willing to look. If that's a compliance problem, use a private DNS zone (BYOD into a subdomain your DNS team controls) or accept that internal IPs in public DNS are routine for fleet operators.
Naming conventions
Hostnames at fleet scale are an interface, not a label. They get typed by techs at 3 a.m., pasted into incident tickets, and matched against asset databases — make them deterministic and predictable, not memorable. A good template:
<role>-<serial-or-asset-id>-<site-slug>.<fleet-zone>.<company-domain>
For the postal-locker fleet that gives names like locker-247-stockholm.lockers.example.com or kiosk-0042-malmoe.kiosks.example.com. The role makes log filters readable, the serial uniquely identifies the device against your CMDB, the site slug means an operator can grep -stockholm and see all 89 devices in one city without consulting a sheet. The fleet-zone segment lets you delegate the whole subtree to DynIP as a BYOD namespace, so DNS administration stays in one place even as you spin up additional fleets under their own subtrees.
Resist the urge to embed status in the name (no -active-, no -spare-, no -stage-). That data belongs in your CMDB; cramming it into DNS guarantees you'll be renaming devices when state changes, which is a category of work nobody enjoys.
Programmatic provisioning
For more than a handful of devices, you don't want to click "Register zone" in the dashboard N times. DynIP's REST API mirrors the dashboard one-to-one: POST /register creates a zone and returns the TSIG key, GET /list enumerates what you have, DELETE /delete tears one down. Authentication is either a short-lived JWT (what the dashboard uses) or a long-lived API token (the right choice for automation; create one in the dashboard under Account → API Tokens, Pro tier or above).
A minimal, idempotent provisioner that takes a list of device IDs and creates one zone per device, writing the TSIG keys to a CSV. Run it once to seed a new fleet, or re-run safely to fill in any zones that failed the first time — the already-exists 409 from the API is caught and treated as success.
#!/usr/bin/env python3
"""Bulk-provision DynIP zones for a fleet.
Reads device IDs from stdin (one per line), creates one zone per device
under the given base namespace, writes a CSV mapping hostname -> TSIG key.
Idempotent: re-runs only fill in missing zones.
Usage:
export DYNIP_TOKEN=dynip_pat_xxxxxxxxxxxxxxxxxx
echo -e "247-stockholm\\n248-stockholm\\n101-malmoe" \\
| ./provision.py --base lockers.example.com --out keys.csv
"""
import argparse, csv, os, sys, time
import requests
API = "https://api.dynip.dev"
TOKEN = os.environ["DYNIP_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def provision(subdomain: str, base: str) -> tuple[str, str | None]:
"""Returns (fqdn, tsig_key). tsig_key is None if the zone already
existed (we don't surface the existing secret via the API)."""
fqdn = f"{subdomain}.{base}"
r = requests.post(
f"{API}/register",
params={"subdomain": f"locker-{subdomain}", "base_domain": base},
headers=HEADERS,
timeout=15,
)
if r.status_code == 409:
return fqdn, None # already provisioned, skip
r.raise_for_status()
body = r.json()
return body["domain"].rstrip("."), body["key"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", required=True, help="e.g. lockers.example.com")
ap.add_argument("--out", required=True, help="CSV output path")
args = ap.parse_args()
with open(args.out, "a", newline="") as f:
w = csv.writer(f)
for line in sys.stdin:
sub = line.strip()
if not sub:
continue
fqdn, key = provision(sub, args.base)
if key:
w.writerow([fqdn, key])
print(f"created {fqdn}", file=sys.stderr)
else:
print(f"skipped {fqdn} (already exists)", file=sys.stderr)
time.sleep(0.5) # be polite to the rate limiter
if __name__ == "__main__":
main()
Three things to keep this safe at scale. The 0.5-second sleep keeps you well under the per-account rate limit (currently 60 update requests / minute, with create operations on a tighter budget). The CSV append-mode lets you re-run incrementally without losing the keys you already captured. The raise_for_status escalates on anything other than 200 or 409 — for a real production run you'd want a retry-with-backoff wrapper around requests.post as well, but the shape stays the same.
Store the resulting CSV in a secrets manager (Vault, AWS Secrets Manager, or for smaller fleets just an age-encrypted file in the same repo as your provisioning code). The TSIG key is the only thing standing between an attacker and the ability to write your DNS record — treat it accordingly.
Device-side automation
Getting the DDNS config onto the device is fleet-management-platform-specific. The DynIP side stays identical — same TSIG key, same hostname, same update endpoint — what changes is how you push the per-device config. Brief pointers:
- Teltonika RMS / RUT firmware: templated configurations in RMS, with per-device variable substitution from a CSV identical in shape to the one your provisioner wrote.
- OpenWrt / OpenWRT-based firmware (RUTOS, GL.iNet, custom): a UCI template pushed via the device's configuration management agent —
uci set ddns.myddns.password='{{ tsig_key }}'and friends. - MikroTik RouterOS: an exported
.rscfile per device, pushed over the MikroTik API or scp-ed to/flash/for first-boot. The MikroTik guide has the exact script body. - Linux-based edge boxes (Raspberry Pi, x86 industrial PCs): a one-line cron job plus the secret in
/etc/dynip.env, both delivered by your config management tool of choice (Ansible, Salt, NixOS modules, cloud-init).
The pattern in every case is: provisioner script produces the per-device secret material, fleet management platform delivers it to the device, device runs the standard DDNS loop. DynIP itself is unchanged.
Monitoring and drift detection
The most useful fleet-level monitoring signal DynIP gives you is "when did each zone last receive an update". A device that stops updating is silently broken — the DNS record stays valid (it just keeps resolving to the last-known IP) but the device itself is dark. You want to know within hours, not when someone notices the locker isn't responding.
Authenticated GET /list returns every zone owned by the account, each with a last_sync timestamp (the most recent A or AAAA write, whether via HTTP /update or native RFC 2136). Run a small probe that pulls the list periodically and alerts when any zone's last_sync is older than your expected update interval plus a tolerance:
import os, requests
from datetime import datetime, timezone, timedelta
API = "https://api.dynip.dev"
STALE_AFTER = timedelta(hours=2) # device updates every 10 min; 2h = ~12 misses
r = requests.get(f"{API}/list",
headers={"Authorization": f"Bearer {os.environ['DYNIP_TOKEN']}"},
timeout=15)
r.raise_for_status()
now = datetime.now(timezone.utc)
stale = []
for zone in r.json():
last = zone.get("last_sync")
if not last:
continue
age = now - datetime.fromisoformat(last.replace("Z", "+00:00"))
if age > STALE_AFTER:
stale.append((zone["domain"], age))
for domain, age in stale:
print(f"STALE: {domain} ({age} since last update)")
Wire the output into whatever your fleet uses — PagerDuty, Slack, a Prometheus textfile collector. For larger fleets, group the alerts by site or device-role so a regional outage doesn't generate one ticket per device.
DynIP and Teltonika RMS
Operators of Teltonika RUTX fleets often already run RMS (Teltonika Remote Management System) or are weighing it against alternatives. RMS handles device-management concerns: firmware push, configuration templating, an RMS Connect tunnel to reach a device's UI without exposing it publicly, alerting on device health. It does not (deliberately) handle the DNS layer — RMS Connect gives you a session-scoped URL through their tunnel, not a stable name your other infrastructure can dial.
DynIP and RMS are complementary, not competing. The postal-locker example uses both: RMS provisions the DDNS script as part of the device's standard configuration template, and DynIP is the layer that publishes locker-247-stockholm.lockers.example.com so the locker software's API server has a stable name for the lockers to dial when they need to push transaction data. RMS handles the management plane; DynIP handles the addressing plane.
Anti-patterns
Treating DynIP as inbound reachability on CGNAT
Publishing a CGNAT-range IPv4 address doesn't make the device reachable from outside the carrier — it just makes the address visible. If you need inbound, you need a tunnel (or IPv6 if both ends support it). The DNS record is useful for tracking and DNS-01, not connectivity.
Device-to-device addressing over public DNS
If two devices on a private APN need to talk to each other, don't have them resolve each other's public hostnames — the resolution works, but the address that comes back is only routable inside the APN, and if either device's resolver is non-APN-aware, you'll get intermittent failures. Use the carrier's internal DNS resolver (which the devices' DHCP already points them at) or a private DNS view.
Forgetting the 60-second TTL when caching
DynIP's records are issued with TTL 60. Most resolvers honor that; some — corporate split-horizon resolvers, certain CDN edges — round TTLs up to a minimum that's much higher. If a downstream system is reading stale IPs after a device move, walk the resolution path and find the resolver that's overriding TTL.
Secrets in the same repo as the device firmware
A common drift pattern: the provisioner writes a CSV of TSIG keys, someone commits it "just temporarily" so a colleague can grab a value, and a year later the keys are in git history. Use a secrets manager from the start. The provisioner script above writes to a local CSV intentionally so the next step (ingesting it into your secrets backend) is explicit.
Not respecting rate limits during bulk operations
Provisioning two thousand zones in a tight loop will trip the rate limiter (currently 60 requests/minute on /update, lower on mutating endpoints). The 0.5-second sleep in the provisioner above keeps you within budget for the create path. For bulk re-keying or wholesale tear-downs, sleep longer or run the operation in batches across hours.
Related guides
- MikroTik RouterOS DDNS — the device-side script the fleet provisioner deploys, with the private-APN discovery variant.
- FortiGate DDNS — same RFC 2136 / TSIG protocol on FortiOS, for branch-firewall fleets.
- Tailscale + DynIP — the canonical tunnel pairing when CGNAT or private addressing blocks inbound.