Introduction
Dynamic DNS is one of those things that sounds trivial until it breaks your entire remote access setup.
Because #lazy, I was relying on the router’s built-in DDNS (noip.com), which worked… until it didn’t. The DNS record stopped reflecting the actual WAN IP, and suddenly my WireGuard tunnels across sites became unreliable, that and the fact that noip.com services are "freeware" and there is a nuance about monthly validation.
Instead of debugging opaque router behavior, I decided to take control of the problem. The result is a small Python-based Dynamic DNS updater for deSEC.io, designed to run from a Linux VM, LXC, or any boring little Unix box that understands its job and does not try to be clever.
What it does
- Updates the public IP of YOUR.domain.com
- Uses deSEC.io as the DNS provider
- Runs independently of the router
- Updates DNS when the IP actually changes
- Simple, transparent, and easy to debug, including logging
- Multiple hostname support
- IPv4 and IPv6 support
- JSON configuration
- Environment variable support
- Command-line overrides
- dry-run mode
- Pushover notifications
- state tracking, loggin
- cleaner failure behavior
Pre-Requisites
You will need:
- A domain or subdomain hosted on deSEC.io
- A deSEC token
- A Linux host, VM, or LXC
- Python 3
- Basic shell access
- Optional: Pushover account for notifications
Why deSEC.io
deSEC is a DNS hosting service with:
- Full API access
- Token-based authentication
- Native DynDNS endpoint
- No dependency on proprietary router integrations
- German based and friendly with Privacy and Community
Most important, it behaves predictably and focuses on doing what it does best.
The script talks to:
https://api.ipify.orghttps://ipv4.icanhazip.comhttps://api64.ipify.orghttps://ipv6.icanhazip.comhttps://update.dedyn.io/https://api.pushover.net/1/messages.jsonif pushover notifications are enabled
Architecture
The solution is intentionally simple:
[ *nix Based LXC/VM ]
│
├── Python script
│ │
│ ├── Load config
│ │ ├── defaults
│ │ ├── JSON config file
│ │ ├── environment variables
│ │ └── command-line arguments
│ │
│ ├── Read deSEC token
│ │
│ ├── Detect public IP
│ │ ├── IPv4
│ │ └── IPv6 optional
│ │
│ ├── Compare against local state
│ │ ├── hostname.ipv4
│ │ └── hostname.ipv6
│ │
│ ├── Update deSEC only if changed
│ │
│ └── Send Pushover notification optional
│
└── deSEC DNS
├── home.example.com → WAN IPv4
├── home.example.com → WAN IPv6
├── vpn.example.com → WAN IPv4
└── vpn.example.com → WAN IPv6
The Script
The current version is 2.0, hosted at Codeberg:
The updated version now supports:
Multiple Hostnames
You can update more than one hostname in the same run.
Example:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--hostname vpn.example.com
This is useful when multiple services depend on the same WAN address:
- VPN endpoint
- reverse proxy
- monitoring endpoint
- remote admin hostname
- lab service entry point
Each hostname gets its own state tracking.
IPv4 and IPv6 Support IPv4 is enabled by default. IPv6 is supported but disabled by default.
To enable IPv6:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--ipv6
To disable IPv4:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--no-ipv4 \
--ipv6
The script stores IPv4 and IPv6 state separately:
/var/lib/desec-ddns/home.example.com.ipv4
/var/lib/desec-ddns/home.example.com.ipv6
This matters because WAN IPv4 and IPv6 prefixes may change independently.
Preserve Behavior
When the script updates only IPv4, it sends:
myipv6=preserve
When it updates only IPv6, it sends:
myipv4=preserve
This prevents the script from accidentally overwriting or clearing the other record type.
That was one of the original lessons from the first version: do not assume IPv6 should be touched just because IPv4 changed.
Configuration precedence:
- built-in defaults
- JSON config file
- environment variables
- command-line arguments
Command-line-love.
Installation
- Install the Script
Copy the script:
sudo cp desec-ddns.py /usr/local/bin/desec-ddns.py
sudo chmod +x /usr/local/bin/desec-ddns.py
- Create Required Directories
sudo mkdir -p /etc/desec-ddns /var/lib/desec-ddns - Create the deSEC Token File
sudo nano /etc/desec-ddns/tokenPaste your deSEC token.
- No quotes.
- No spaces.
-
No decorative nonsense.
And we standard-harden-it:
sudo chmod 600 /etc/desec-ddns/token sudo chown root:root /etc/desec-ddns/token- Optional: Create a JSON Config File
Default config paths checked by the script:
/etc/desec-ddns/config.json
~/.config/desec-ddns/config.json
./desec-ddns.json
Example:
{
"hostnames": ["home.example.com", "vpn.example.com"],
"token_file": "/etc/desec-ddns/token",
"state_dir": "/var/lib/desec-ddns",
"ipv4_enabled": true,
"ipv6_enabled": true,
"dry_run": false,
"pushover": {
"token": "your_pushover_app_token",
"user": "your_pushover_user_key"
},
"log_level": "INFO"
}
Secure it if it contains notification tokens:
sudo chmod 600 /etc/desec-ddns/config.json
sudo chown root:root /etc/desec-ddns/config.json
Manual Test
Run a dry-run first:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--dry-run \
--log-level DEBUG
This shows what the script would do without touching DNS.
Then verify current IP detection:
curl -4 -s https://api.ipify.org ; echo
curl -4 -s https://ipv4.icanhazip.com ; echo
For IPv6:
curl -6 -s https://api64.ipify.org ; echo
curl -6 -s https://ipv6.icanhazip.com ; echo
Verify DNS:
dig +short home.example.com
dig +short AAAA home.example.com
Schedule with Cron
Edit root cron:
sudo crontab -e
Run hourly:
0 * * * * /usr/local/bin/desec-ddns.py >> /var/log/desec-ddns.log 2>&1
Run every five minutes:
*/5 * * * * /usr/local/bin/desec-ddns.py >> /var/log/desec-ddns.log 2>&1
Hourly is usually good enough.
Configuration Reference
Command-Line Arguments
-H, --hostname Hostname or hostnames to update
-t, --token-file Path to deSEC token file
-s, --state-dir Directory for state files
--ipv4 Enable IPv4
--no-ipv4 Disable IPv4
--ipv6 Enable IPv6
--no-ipv6 Disable IPv6
-n, --dry-run Show what would happen without updating DNS
--pushover-token Pushover application token
--pushover-user Pushover user key
-l, --log-level DEBUG, INFO, WARNING, ERROR
-v, --version Show script version
Example:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--hostname vpn.example.com \
--ipv6 \
--log-level INFO
Environment Variables
DESEC_HOSTNAMES Comma-separated list of hostnames
DESEC_TOKEN_FILE Path to token file
DESEC_STATE_DIR Directory for state files
DESEC_DRY_RUN 1, true, or yes
DESEC_IPV4_ENABLED 1, true, or yes
DESEC_IPV6_ENABLED 1, true, or yes
PUSHOVER_TOKEN Pushover application token
PUSHOVER_USER Pushover user key
LOG_LEVEL DEBUG, INFO, WARNING, ERROR
Example:
export DESEC_HOSTNAMES="home.example.com,vpn.example.com"
export DESEC_TOKEN_FILE="/etc/desec-ddns/token"
export DESEC_IPV6_ENABLED="true"
sudo /usr/local/bin/desec-ddns.py
Pushover Notifications
The script can send Pushover notifications when:
- an IP update succeeds
- an update fails
This is useful because DDNS is one of those services you only notice when it has already betrayed you and jellyfin is NOT streaming.
Example command-line usage:
sudo /usr/local/bin/desec-ddns.py \
--hostname home.example.com \
--pushover-token YOUR_PUSHOVER_APP_TOKEN \
--pushover-user YOUR_PUSHOVER_USER_KEY
Example config:
{
"hostnames": ["home.example.com"],
"pushover": {
"token": "your_pushover_app_token",
"user": "your_pushover_user_key"
}
}
Notification title:
deSEC DDNS: home.example.com
Example message:
IPv4: 198.51.100.10 -> 198.51.100.25 IPv6: none -> 2001:db8::1234
Failure notifications use:
deSEC DDNS FAILED: hostname
Running as Non-Root
The script can run as root, but it does not need to.
A more secure setup is to create a dedicated user:
sudo useradd -r -s /usr/sbin/nologin desec-ddns
Create directories:
sudo mkdir -p /etc/desec-ddns /var/lib/desec-ddns /var/log/desec-ddns
Set ownership:
sudo chown -R desec-ddns:desec-ddns /etc/desec-ddns
sudo chown -R desec-ddns:desec-ddns /var/lib/desec-ddns
sudo chown -R desec-ddns:desec-ddns /var/log/desec-ddns
Install the script:
sudo cp desec-ddns.py /usr/local/bin/desec-ddns.py
sudo chown desec-ddns:desec-ddns /usr/local/bin/desec-ddns.py
sudo chmod +x /usr/local/bin/desec-ddns.py
Edit that user’s crontab:
sudo crontab -u desec-ddns -e
Add:
0 * * * * /usr/local/bin/desec-ddns.py >> /var/log/desec-ddns/desec-ddns.log 2>&1
Common Issues
I still believe that Issues are Common...sigh
Token File Not Found
Check: ls -l /etc/desec-ddns/token Run with explicit path: sudo /usr/local/bin/desec-ddns.py \ --hostname home.example.com \ --token-file /etc/desec-ddns/token
Token File Is Empty
Open the token file: sudo nano /etc/desec-ddns/token Paste the deSEC token only.
Permission Denied Reading Token
Fix permissions: sudo chmod 600 /etc/desec-ddns/token sudo chown root:root /etc/desec-ddns/token
Or, if running as the dedicated user: sudo chown desec-ddns:desec-ddns /etc/desec-ddns/token sudo chmod 600 /etc/desec-ddns/token
No Hostnames Configured
Set one using command-line: sudo /usr/local/bin/desec-ddns.py --hostname home.example.com Or environment variable: export DESEC_HOSTNAMES="home.example.com" Or JSON config:
{
"hostnames": ["home.example.com"]
}
IPv4 Detection Failed
Test manually: curl -4 -s https://api.ipify.org ; echo curl -4 -s https://ipv4.icanhazip.com ; echo If both fail, the host probably does not have working IPv4 egress.
IPv6 Detection Failed
Test manually: curl -6 -s https://api64.ipify.org ; echo curl -6 -s https://ipv6.icanhazip.com ; echo If these fail, IPv6 is either not configured, not routed, or being eaten by some network device with delusions of competence.
Disable IPv6 updates: sudo /usr/local/bin/desec-ddns.py \ --hostname home.example.com \ --no-ipv6
deSEC HTTP 401 Usually means the token is wrong. Check the token. Generate a fresh one if needed.
deSEC HTTP 403 Usually means the token does not have permission for that hostname. Check token permissions in deSEC.
Key Implementation Details
The script now:
- discovers IPv4 and IPv6 through multiple fallback services
- validates detected IPs before using them
- stores state per hostname and per IP version
- sanitizes hostnames before using them as filenames
- preserves the untouched IP family during updates
- avoids unnecessary API calls
- supports dry-run testing
- sends optional Pushover notifications
- exits cleanly on configuration errors
- uses a custom User-Agent: desec-ddns/2.0
The state model looks like this:
/var/lib/desec-ddns/
├── home.example.com.ipv4
├── home.example.com.ipv6
├── vpn.example.com.ipv4
└── vpn.example.com.ipv6
Makes it easy to inspect.
No database. No daemon. No orchestration platform wearing a tiny hat.
Just files.
Files are good.
Files tell the truth.
Mostly.
Why Not Use the Router?
Because you do not control:
- update frequency
- retry behavior
- logging
- error handling
- IPv6 behavior
- state tracking
- notifications
- failure visibility
When router DDNS fails, you guess.
This script gives you:
- visibility
- reproducibility
- portability
- dry-run testing
- explicit config
- better logging
- predictable behavior
The router can go back to forwarding packets and blinking LEDs.
Lessons Learned
- Built-in DDNS features are often black boxes.
- DNS consistency matters when VPN and remote access depend on it.
- IPv6 should be handled explicitly, not accidentally.
- State files are boring and useful, which is the highest compliment infrastructure can receive.
- Notifications are worth adding once the script becomes operationally important.
- Owning the logic beats trusting magic.
Conclusion
The original goal was simple:
Replace unreliable router DDNS with something I could understand, control, and debug.
The updated version keeps that same philosophy, but adds the features that make it more useful in a real homelab:
let us face the truth, we all believe that #homelabs are personal little datacenters
- multiple names
- dual-stack support
- safer update behavior
- layered configuration
- dry-run validation
- notifications
- non-root execution support
It is still just a Python script.
That is the point.
Small tools, clearly understood, running on boring infrastructure.