Primer ddclient multi-domain support
Solving ddclient updates for multiple domains on Namecheap [OpenBSD specific, reproducible]
I run several domains on Namecheap, on a single OpenBSD host to keep A records in sync with my WAN IP. I started with a single /etc/ddclient/ddclient.conf that listed all domains and hosts. Only the last domain updated. The logs insisted everything was fine, but authoritative DNS did not move for the earlier domains.
This post documents the root cause and a production ready fix, including exact file paths, permissions, service wiring, and verification. It targets ddclient 3.9.1 on OpenBSD, Namecheap Dynamic DNS, and multiple domains with repeated host labels, for example @, mail, obsd1.
tl,dr
ddclient 3.9.1 keys its in memory config by host label, not by the pair [domain, host]. If you repeat labels across domains inside one config file, the last domain block wins. The fix, split one config per domain, give each a unique cache file, run them serially with a tiny driver, and log everything.
Symptoms
- Only the last domain in
/etc/ddclient/ddclient.confupdates ddclient -debugshowsconfig{mail}{login}set to the last domain for all labels- Logs show
good: IP address set ...but authoritative DNS stays unchanged for earlier domains - Intermittent
FATAL: Cannot create file '/var/cache/ddclient/ddclient.cache'when cache permissions are wrong
Minimal reproduction
# one file with many domains that share labels [@, mail, obsd1]
# ddclient only acts on the last block
/etc/ddclient/ddclient.conf
Correct mental model
One config entry per [domain, host] pair, unique cache per domain, iterate configs.
Final layout
We will replace the single file with:
- One driver:
/usr/local/sbin/ddclient-multi.sh - One config per domain:
/etc/ddclient/<domain>.conf - One cache per domain:
/var/cache/ddclient/<domain>.cache - rcctl flags that run the driver every 300 seconds, the driver runs each config once
1) Driver [serial executor, syslog, robust]
File path
/usr/local/sbin/ddclient-multi.sh
Change description add driver that iterates per domain configs, calls ddclient for each, logs to daemon facility
Git commit comment
add ddclient driver to run one config per domain and log to daemon
#!/bin/sh
set -eu
CONF_DIR="/etc/ddclient"
BIN="/usr/local/sbin/ddclient"
LOGTAG="ddclient-multi"
if ! command -v "$BIN" >/dev/null 2>&1; then
logger -p daemon.notice -t "$LOGTAG" "error, ddclient not found at $BIN"
echo "error, ddclient not found at $BIN" >&2
exit 1
fi
found=0
for cfg in "$CONF_DIR"/*.conf; do
[ -f "$cfg" ] || continue
found=1
logger -p daemon.notice -t "$LOGTAG" "running $cfg"
"$BIN" -file "$cfg" -daemon=0 -syslog
done
if [ "$found" -eq 0 ]; then
logger -p daemon.notice -t "$LOGTAG" "warning, no config files found under $CONF_DIR"
echo "warning, no config files found under $CONF_DIR" >&2
fi
doas install -o root -g wheel -m 0755 /usr/local/sbin/ddclient-multi.sh
2) Per domain configs
Each file includes: its own cache path, Namecheap protocol and server, the domain login, the domain specific password, and the host labels to update.
Common prework, cache and pid directories
doas install -d -o _ddclient -g _ddclient -m 0750 /var/cache/ddclient
doas install -d -o _ddclient -g _ddclient -m 0755 /var/run/ddclient
Example, waywardclowns.com
File path
/etc/ddclient/waywardclowns.com.conf
Change description add ddclient config for waywardclowns.com with unique cache and hosts
Git commit comment
add waywardclowns.com ddclient config with per-domain cache
daemon=0
syslog=yes
ssl=yes
use=web, web=checkip.amazonaws.com, web-skip='^Address'
cache=/var/cache/ddclient/waywardclowns.com.cache
pid=/var/run/ddclient/ddclient.pid
protocol=namecheap
server=dynamicdns.park-your-domain.com
login=waywardclowns.com
password=REDACTED_waywardclowns_com
@, mail, obsd1
Repeat for your other domains. Use the same structure, only change the names and passwords, and adjust the host list. For a domain that uses GitHub Pages on the apex, do not include @.
Template for the rest
Replace <domain> and <password> and host list as needed.
File path
/etc/ddclient/<domain>.conf
Change description
add ddclient config for
Git commit comment
add <domain> ddclient config with per-domain cache
daemon=0
syslog=yes
ssl=yes
use=web, web=checkip.amazonaws.com, web-skip='^Address'
cache=/var/cache/ddclient/<domain>.cache
pid=/var/run/ddclient/ddclient.pid
protocol=namecheap
server=dynamicdns.park-your-domain.com
login=<domain>
password=<password>
@, mail, obsd1
Permissions, ownership, empty caches
doas chown _ddclient:_ddclient /etc/ddclient/*.conf
doas chmod 0600 /etc/ddclient/*.conf
for f in /etc/ddclient/*.conf; do
base="$(basename "$f" .conf)"
doas sh -c ": > /var/cache/ddclient/${base}.cache"
doas chown _ddclient:_ddclient "/var/cache/ddclient/${base}.cache"
done
3) Service wiring [rcctl]
Change description configure ddclient service to run the driver every 300 seconds
Git commit comment
wire ddclient to driver using rcctl flags with 5 minute interval
doas rcctl set ddclient flags "-daemon=300 -exec /usr/local/sbin/ddclient-multi.sh"
doas rcctl restart ddclient
doas rcctl get ddclient flags
doas rcctl check ddclient
4) Remove the old monolithic file
If you leave /etc/ddclient/ddclient.conf in place, your driver will also try to run it, which reintroduces the generic cache path, and the last domain wins bug.
Change description disable legacy config so the driver cannot run it
Git commit comment
disable legacy single ddclient.conf to prevent fallback
doas test -f /etc/ddclient/ddclient.conf && doas mv /etc/ddclient/ddclient.conf /etc/ddclient/ddclient.conf.disabled
Verification, end to end
Authoritative DNS, query Namecheap name servers
for d in waywardclowns.com blackbagsecurity.io blackbagsecurity.com stumblesthedrunk.com redactedsecurity.ca redactedsolar.energy; do
echo "=== $d"
drill -Q -t -4 @dns1.registrar-servers.com A $d || true
drill -Q -t -4 @dns1.registrar-servers.com A mail.$d || true
drill -Q -t -4 @dns1.registrar-servers.com A obsd1.$d || true
done
# example for a domain with GitHub Pages apex, only check dynamic hosts
echo "=== unattributed.blog"
drill -Q -t -4 @dns1.registrar-servers.com A mail.unattributed.blog || true
drill -Q -t -4 @dns1.registrar-servers.com A obsd1.unattributed.blog || true
You should see the WAN IP for each checked host, not 127.0.0.1, not 10.10.10.10.
Logs, driver plus ddclient
The driver logs to the daemon facility. Watch both daemon and messages, depending on your syslogd.conf.
doas tail -n 200 /var/log/daemon | egrep 'ddclient-multi|SETDNSHOST|good: IP address set' || true
doas tail -n 200 /var/log/messages | egrep 'ddclient-multi|ddclient' || true
Look for running /etc/ddclient/<domain>.conf, followed by three SETDNSHOST calls for each domain.
Manual one shot, helpful for first run
doas /usr/local/sbin/ddclient-multi.sh
Why the single file failed
ddclient -debug reveals the problem clearly. config{@}{login}, config{mail}{login}, and config{obsd1}{login} all held the same domain, the last block in the file. That happens because ddclient maps entries by host label only, so repeated labels collide. The per domain split prevents collisions, each process uses a different config and a different cache.
Namecheap API probe [independent of ddclient]
If a specific host refuses to move, verify credentials and host existence with the raw API. Replace variables accordingly.
DOMAIN=example.com
HOST=obsd1
PASS='your_namecheap_dynamic_dns_password_for_example.com'
IP=$(ftp -o - https://checkip.amazonaws.com | tr -d ' \n\r')
ftp -o - "https://dynamicdns.park-your-domain.com/update?host=${HOST}&domain=${DOMAIN}&password=${PASS}&ip=${IP}"
You want <ErrCount>0</ErrCount> and <Done>true</Done>. If you see “A Record not found”, create an “A + Dynamic DNS” host in Namecheap first, save with a placeholder, then run ddclient again.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Only last domain updates | Config collision on repeated labels | Split per domain configs, unique caches, run with driver |
| `FATAL, Cannot create ... ddclient.cache` | Cache path unwritable, or legacy single file still in use | Create per domain caches owned by `_ddclient`, remove legacy file |
| `good: IP address set`, DNS unchanged | You looked at resolver cache or public resolvers with lag | Query `@dns1.registrar-servers.com` and peers directly |
| A host sticks at 127.0.0.1 or 10.10.10.10 | Host is not “A + Dynamic DNS” at Namecheap | Create “A + Dynamic DNS” for that host, then rerun |
| No log lines in `/var/log/daemon` | Syslog routes to `/var/log/messages` on your host | Tail both files, or change `logger -p daemon.notice` in driver as desired |
Security notes
- Store per domain configs as
_ddclient:_ddclient, mode 0600 - Do not commit real passwords, use your secret store, or template the files at deploy time
- The driver runs as root via rc, ddclient itself drops privileges to
_ddclientwhile writing caches if installed from packages
Closing
The fix is simple once you know the constraint, treat each domain separately, keep caches separate, iterate with a small driver, and query the authoritative DNS to verify. This has been running cleanly for me on OpenBSD, with clear logs and no collisions.
If you have an alternative approach that keeps a single process without collisions, for example a newer ddclient or a patch that keys by [domain, host], I would love to compare notes.