A stable domain name for a home server whose IP keeps changing: DIY dynamic DNS
Why your home IP changes, and a 60-line Python script that checks it every 5 minutes and updates an A record through your DNS provider's API, DigitalOcean as the example.
- A home IP can change on every router reboot or whenever the ISP reshuffles, so something has to keep the domain name in step
- Dynamic DNS fits in 60 lines, ask for the public IP, compare with the last one you remembered, call the API only when it differs
- A TTL of 300 seconds lets the outside world see the new IP within about 5 minutes
- The DNS token lives in a .env file outside the repo, and a name pointing at your house is not an open door, you still need a firewall or VPN
A small server at home works fine until the night the power blips, the router reboots, the ISP hands out a new address and home.example.com suddenly points at someone else’s house. The fix is Dynamic DNS (DDNS): the machine at home tells DNS what its IP is right now. This setup has kept two names (home and code) pointing home since the start of the year: one Python file, one cron line, no extra service.
Tested on Ubuntu 24.04 with Python 3.12 and DNS hosted at DigitalOcean, but any provider with an API for editing records works the same way.
Why a home IP changes
Most home connections get a public IP on loan from the ISP, much like devices at home borrow an address from the router (that side is covered in the article on cameras and IoT devices changing IP). This one is the address of the house itself, and it changes when:
- the router reboots or the power goes out; a fresh PPPoE session usually gets a fresh IP
- the ISP forces a reconnect on a schedule, every 24 to 72 hours with some providers
- the ISP reshuffles its network and moves customers to another address pool
A static IP costs extra and some plans don’t offer it, so DDNS is the standard answer for a home server.
The idea: remember the last IP, touch DNS only when it changes
Every 5 minutes cron runs a script that does four things:
- asks an outside service “what is my IP?”
- compares it with the last IP written to a small JSON file
- if they match, stops. The API is not called at all
- if they differ, calls the DNS API to change the A record, then writes the new IP to the file
Step 3 matters. The IP changes a few times a month, but cron runs 288 times a day; calling the API every run would hit rate limits and fill the log for nothing. A local file is far cheaper to compare against than live DNS.
Why ask two services for the IP
Services like api.ipify.org or checkip.amazonaws.com reply with the caller’s IP as one line of text. Free, but nobody promises they stay up. The day the first one is down, the script must not conclude “the internet is broken” and quit, so it tries the second before giving up, and checks that the reply is really an IPv4 address, not an HTML error page.
The script
The real one uses httpx; this version is trimmed to urllib from the standard library so there is nothing to pip install. Save it as ~/scripts/ddns-update.py.
#!/usr/bin/env python3
"""ddns-update.py DOMAIN SUBDOMAIN — update one A record when the public IP changes."""
import json, os, sys, urllib.request
from datetime import datetime
from pathlib import Path
IP_SERVICES = ["https://api.ipify.org", "https://checkip.amazonaws.com"]
API = "https://api.digitalocean.com/v2" # change for another provider
TTL = 300
def http(url, method="GET", body=None, token=None):
req = urllib.request.Request(url, method=method,
data=json.dumps(body).encode() if body else None)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=15) as r:
return r.read().decode().strip()
def public_ip():
for url in IP_SERVICES:
try:
ip = http(url)
parts = ip.split(".")
if len(parts) == 4 and all(p.isdigit() and int(p) < 256 for p in parts):
return ip
except Exception as e:
print(f"warn: {url}: {e}", file=sys.stderr)
sys.exit("could not detect public IP")
def update_record(domain, sub, ip, token):
url = f"{API}/domains/{domain}/records?type=A&name={sub}.{domain}"
records = json.loads(http(url, token=token))["domain_records"]
rec = next((r for r in records if r["name"] == sub), None)
if rec is None:
http(f"{API}/domains/{domain}/records", "POST",
{"type": "A", "name": sub, "data": ip, "ttl": TTL}, token)
elif rec["data"] != ip:
http(f"{API}/domains/{domain}/records/{rec['id']}", "PUT", {"data": ip}, token)
def main():
domain, sub = sys.argv[1], sys.argv[2]
token = os.environ["DO_API_TOKEN"]
cache = Path(__file__).with_name(f".ddns_{sub}.json")
ip = public_ip()
last = json.loads(cache.read_text())["ip"] if cache.exists() else ""
if ip == last:
return # the usual case: nothing to do
update_record(domain, sub, ip, token)
cache.write_text(json.dumps({"ip": ip, "updated": datetime.now().isoformat()}))
print(f"{datetime.now():%F %T} {sub}.{domain}: {last or '-'} -> {ip}")
if __name__ == "__main__":
main()
The only DigitalOcean-specific part is update_record: GET /v2/domains/{domain}/records to find the record’s id, then PUT /v2/domains/{domain}/records/{id} with {"data": "<new IP>"}. If the record doesn’t exist yet it POSTs one with ttl: 300. Other providers (Cloudflare, Gandi, Porkbun) have APIs of the same shape; only the URL and field names differ.
The cache is one file per name and looks like this:
{"ip": "203.0.113.42", "updated": "2026-10-21T06:05:02"}
One wrapper per subdomain, and cron
The token is not in the script and not in the repo. It sits in ~/.env with chmod 600. The wrapper’s job is to load that file and call the script with the name it owns. One wrapper per name, so names can be added or dropped without touching code.
#!/bin/bash
# ~/scripts/ddns-home.sh
set -a; source ~/.env; set +a # DO_API_TOKEN=dop_v1_...
python3 ~/scripts/ddns-update.py example.com home
# crontab -e (on the machine at home)
*/5 * * * * ~/scripts/ddns-home.sh >> ~/scripts/ddns.log 2>&1
*/5 * * * * ~/scripts/ddns-code.sh >> ~/scripts/ddns.log 2>&1
The log stays almost empty, because a run where nothing changed prints nothing. To see when the IP last moved: grep -- '->' ~/scripts/ddns.log.
Choosing the TTL
The TTL is how long resolvers around the world keep a DNS answer before asking again. Two forces pull against each other:
| TTL | Worst case until the new IP is seen | Queries at the DNS | Good for |
|---|---|---|---|
| 30 s (DigitalOcean’s minimum) | 0.5 min + the 5 min cron interval | most | not worth it, cron is the slower part anyway |
| 300 s | 5 + 5 = about 10 min | moderate | typical DDNS |
| 3600 s | over an hour | few | records that never change |
Worked through: worst case, the IP changes one second after cron ran. The script notices 5 minutes later and fixes DNS; a resolver that cached the old answer just before may hold it another 300 seconds. About 10 minutes in total when the name may point to the wrong place, acceptable for a home. To make it faster, shorten the cron interval first; lowering the TTL alone won’t help.
What breaks when the IP changes, and what doesn’t
- TLS does not break. Certificates are issued for a name, not an IP. As long as the name resolves correctly, certbot and browsers don’t care how many times the address changed.
- Open SSH sessions drop, because TCP is bound to the old pair of addresses. Reconnecting after DNS updates works normally; run
tmuxon the server side and your work survives. - The 10-minute window. Anyone visiting the name may land on someone else’s connection, which usually has nothing listening on that port, so they simply fail to connect.
- Inbound webhooks fired during that window are lost. Anything that receives webhooks should rely on the sender retrying.
DDNS solves one problem only: finding the machine. It makes the machine neither safer nor more exposed. What decides the security is which ports you open on the router.
Security
- Smallest token scope. DigitalOcean lets you scope a token; pick read and write on
domainonly. No droplet creation, no billing. If the token leaks, the damage stops at DNS. - Never in the repo. The scripts folder has a
.env.examplewith fake values so you know which variables are needed. The real.envlives outside the repo and is in.gitignoreas a second layer. - The record points at your house, it doesn’t open it. A domain name revealing your home IP does not let anyone in. If the router forwards no ports, the name points at a closed door. What to open and for whom is covered in reaching your home server without opening ports; if SSH is going to be exposed, read the SSH hardening article first.
- Nothing secret in the log. It prints the name, the IP and the time. Never the token or the full API response.
Alternatives that need no code
| Method | Good | Catch |
|---|---|---|
| Router’s built-in DDNS (No-IP, DynDNS) | set once in the router UI | names like xxx.ddns.net; some require a confirmation every 30 days |
| Router that supports your provider | no machine needed to run a script | most router firmware knows only a handful of providers |
| ddclient or inadyn | many providers, long track record | long config files; some providers need plugins |
| Your own script (this article) | you know what every line does, unlimited names | needs an always-on machine, which a home server already is |
If your router supports your DNS provider directly, use the router. Otherwise, with a Linux box already on around the clock, 60 lines and one cron line are enough.
Sources
- DigitalOcean API: Update a Domain Record, the
PUT /v2/domains/{domain_name}/records/{domain_record_id}call - DigitalOcean DNS Limits, minimum TTL of 30 seconds
- DigitalOcean: How to Create, Edit, and Delete DNS Records, TTL 300 recommended for records that change often
- Release note: domain record TTL, where the minimum and default TTL come from
FAQ
Isn't the router's built-in DDNS (No-IP, DynDNS) easier?
It is, if you are happy with a name like xxx.ddns.net. If you want a name under your own domain and your DNS is already with a provider that has an API, a short script does the same job without signing up for another service.
Why not set the TTL to the minimum, 30 seconds?
A lower TTL means more DNS queries, and many resolvers ignore anything below 300 anyway. The script only checks every 5 minutes, so a lower TTL doesn't actually make the switch faster.
What if my home connection uses IPv6?
Same idea, use an AAAA record instead of A, and ask a service that answers over IPv6 such as api64.ipify.org. Most home ISPs still hand out a changing IPv4 as the primary address.
Commands and settings were tested on sample systems. Try them on a test machine before production.