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.org
  • https://ipv4.icanhazip.com
  • https://api64.ipify.org
  • https://ipv6.icanhazip.com
  • https://update.dedyn.io/
  • https://api.pushover.net/1/messages.json if 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:

Repo Link

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:

  1. built-in defaults
  2. JSON config file
  3. environment variables
  4. command-line arguments

Command-line-love.

Installation

  1. 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
  1. Create Required Directories
    sudo mkdir -p /etc/desec-ddns /var/lib/desec-ddns
  2. Create the deSEC Token File
    sudo nano /etc/desec-ddns/token

    Paste 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
    1. 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.

Previous Post