Du
Networking Azure

DDNS Updater on Ubuntu 24.04 on Azure User Guide

| Product: DDNS Updater on Ubuntu 24.04 LTS on Azure

Overview

DDNS Updater is an open source dynamic DNS client. On a schedule it resolves the machine's current public IPv4 and IPv6 address and, whenever that address changes, calls your DNS provider's API to rewrite the A and AAAA records you have asked it to look after - so a name you own always points at the machine you are running. It supports more than fifty providers including Cloudflare, DuckDNS, Namecheap, GoDaddy, Route 53, deSEC, Hetzner, Porkbun, DynV6 and No-IP, plus a generic custom HTTP provider for anything not on the list, and it can manage several domains and subdomains at once. A built-in web UI lists every record with its provider, IP version, last update result, current IP and previous-IP history, and can force an immediate refresh.

DDNS Updater has no user accounts and no authentication of its own, and its records page reveals your domains and your public IP address. The cloudimg image therefore never exposes it directly: the pinned DDNS Updater 2.10.0 Go binary runs under systemd as an unprivileged service bound to 127.0.0.1:8000, with its health server on 127.0.0.1:9999, and an nginx reverse proxy on port 80 is the only network-reachable surface. nginx puts an HTTP Basic Auth gate in front of it whose password is generated uniquely on the first boot of each VM, so no credential is baked into the image. Backed by 24/7 cloudimg support.

What is included:

  • DDNS Updater 2.10.0 installed as a single static Go binary and running as the ddns-updater systemd service
  • The DDNS Updater web UI on :80 fronted by nginx, with the application bound to loopback only
  • Per-VM HTTP Basic Auth (user admin) protecting the web UI, with a unique password generated on first boot
  • The application running as the dedicated unprivileged ddnsupdater system user with systemd hardening applied
  • A valid empty provider configuration at /var/lib/ddns-updater/config.json, readable only by root and the service, ready for your own provider tokens
  • An unauthenticated /healthz endpoint for Azure Load Balancer health probes
  • ddns-updater.service + nginx.service as systemd units, enabled and active
  • Unattended security updates left enabled
  • 24/7 cloudimg support

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet and subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is ample - DDNS Updater is a small Go process that spends nearly all its time idle. NSG inbound: allow 22/tcp from your management network and 80/tcp for the web UI. You will also need an account with a supported DNS provider and an API token or key for the zone you want to keep updated.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for DDNS Updater by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Then Review + create -> Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name ddns-updater \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --vnet-name <your-vnet> --subnet <your-subnet> \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name ddns-updater --port 80 --priority 1010

Step 3 - Connect to your VM

ssh azureuser@<vm-public-ip>

Step 4 - Confirm the services are running

systemctl is-active ddns-updater.service nginx.service

Both report active. DDNS Updater serves its web UI on the loopback address 127.0.0.1:8000 and its health server on 127.0.0.1:9999; nginx fronts the UI on port 80 and adds the per-VM HTTP Basic Auth gate. Nothing but nginx is reachable from the network:

/usr/local/bin/ddns-updater --version
ps -o user= -C ddns-updater | head -1
ss -tlnH | awk '{print $1, $4}' | grep -E ':8000$|:9999$|:80$' | sort -u

The version is 2.10.0, the process runs as the unprivileged ddnsupdater user, and only port 80 is bound on a public address.

The ddns-updater and nginx services active, DDNS Updater 2.10.0 running as the unprivileged ddnsupdater user, and the listener list showing the web UI on 127.0.0.1:8000 and the health server on 127.0.0.1:9999 with only nginx bound to port 80

Step 5 - Retrieve your web UI password

nginx protects the DDNS Updater web UI with HTTP Basic Auth. The username is admin and a unique password is generated on the first boot of your VM and written to a root-only file:

sudo cat /root/ddns-updater-credentials.txt

This file contains DDNS_UPDATER_USERNAME, DDNS_UPDATER_PASSWORD and the DDNS_UPDATER_URL to open in a browser. On disk the password exists only as a bcrypt hash in /etc/nginx/.ddns-updater.htpasswd, so no plaintext password ships in the image. Store the password somewhere safe.

The per-VM credentials file listing the DDNS Updater URL, the admin username and the generated password, alongside the bcrypt hashed entry in the nginx htpasswd file and the credentials file mode 600 root root

Step 6 - Confirm the health endpoint

nginx serves an unauthenticated health endpoint for load balancers and probes:

curl -s http://localhost/healthz

It returns ok. This endpoint never requires authentication, so it is safe to use as an Azure Load Balancer health probe without handing the probe a password.

Step 7 - Confirm authentication

Because a password is set on first boot, an unauthenticated request to the web UI returns HTTP 401, so nobody reaches DDNS Updater without it. The following reads the per-VM password from the credentials file and proves the round-trip - unauthenticated is rejected, a wrong password is rejected, and only the correct password reaches the UI:

PW=$(sudo grep '^DDNS_UPDATER_PASSWORD=' /root/ddns-updater-credentials.txt | cut -d= -f2-)
echo "healthz : $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/healthz)"
echo "unauth  : $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/)"
echo "wrongpw : $(curl -s -o /dev/null -w '%{http_code}' -u admin:wrong-password http://127.0.0.1/)"
echo "authed  : $(curl -s -o /dev/null -w '%{http_code}' -u admin:$PW http://127.0.0.1/)"
curl -s -u admin:$PW http://127.0.0.1/ | grep -E '<title>|<th>' | sed 's/^[[:space:]]*//' | head -8

It prints healthz : 200, unauth : 401, wrongpw : 401, authed : 200, then the page title and the records table headers - proving the authenticated response really is the DDNS Updater web UI.

The nginx HTTP Basic Auth round-trip showing the health probe returning 200 ok without a password, an unauthenticated request returning 401, a wrong password returning 401, the correct per-VM password returning 200, and the DDNS Updater page title and table headers

The same gate is what a browser meets. Opening http://<vm-public-ip>/ without credentials returns nginx's 401 challenge, and the browser prompts for the username and password before anything is shown:

The DDNS Updater web UI returning an nginx 401 Authorization Required page to a browser request that carries no credentials, showing the web interface is closed by default

Step 8 - Sign in to the web UI

Browse to http://<vm-public-ip>/. Your browser prompts for a username and password: enter admin and the password from Step 5. DDNS Updater opens on its records page, a single table listing every record it manages. On a freshly deployed VM the table is empty, because the image ships with no provider configuration - your DNS credentials are yours to add. Once records are configured the table shows, for each one, the domain and owner, the provider it belongs to, whether it is tracking IPv4 or IPv6, the result of the last update attempt, the address currently published, and any previous addresses.

The DDNS Updater records page listing four managed domains with their owner, provider, IP version, update status of Up to date or Success, and the current public IP address each record now points at

Step 9 - Add your first DNS record

DDNS Updater reads its records from /var/lib/ddns-updater/config.json. The image ships a valid but empty configuration, which you can confirm:

sudo jq . /var/lib/ddns-updater/config.json
sudo stat -c '%a %U:%G %n' /var/lib/ddns-updater/config.json

It prints an empty settings array and mode 640 root:ddnsupdater - the file is readable only by root and the service account, because it is where your provider API tokens will live.

To add a record, edit the file and put one object in settings per DNS record. Each provider takes different fields; the provider documentation lists them all. A Cloudflare record looks like this:

sudo nano /var/lib/ddns-updater/config.json
{
  "settings": [
    {
      "provider": "cloudflare",
      "zone_identifier": "<your-zone-id>",
      "domain": "home.example.com",
      "ttl": 600,
      "token": "<your-api-token>",
      "ip_version": "ipv4"
    }
  ]
}

Then restart the service so it picks the new configuration up:

sudo systemctl restart ddns-updater
systemctl is-active ddns-updater

Check the service after every configuration change. DDNS Updater validates provider settings at start-up and refuses to run if any record is malformed - it logs the reason and exits rather than starting with a broken record. If systemctl is-active reports anything other than active, read the reason with journalctl -u ddns-updater -n 20 (Step 11), fix the entry and restart again.

Step 10 - Force an update and watch it work

DDNS Updater checks your public IP every five minutes by default. You do not have to wait: the web UI's /update endpoint forces an immediate refresh of every record.

PW=$(sudo grep '^DDNS_UPDATER_PASSWORD=' /root/ddns-updater-credentials.txt | cut -d= -f2-)
curl -s -w '\nHTTP %{http_code}\n' -u admin:$PW http://127.0.0.1/update

It returns HTTP 202 and reports how long the refresh took. Visiting http://<vm-public-ip>/update in the browser does the same thing:

The DDNS Updater forced update endpoint reporting that all records were updated successfully and how long the refresh took

Return to the records page and each record shows its refreshed status - Success where the published address was changed, Up to date where it already matched - together with the public IP address now on record:

The DDNS Updater records page immediately after a forced refresh, each of the four records showing a green Up to date or Success status and the current public IP address

Step 11 - Read the records table and the log

Everything the web UI shows is also readable from the shell, which is useful for monitoring and for diagnosing a record that will not update:

PW=$(sudo grep '^DDNS_UPDATER_PASSWORD=' /root/ddns-updater-credentials.txt | cut -d= -f2-)
curl -s -u admin:$PW http://127.0.0.1/ | sed -n '/<tbody>/,/<\/tbody>/p' | sed -E 's/<[^>]*>//g' | grep -v '^[[:space:]]*$'
sudo journalctl -u ddns-updater -n 15 --no-pager

The log records the public IP address DDNS Updater resolved, the address each domain currently resolves to, and every update it sent to a provider - so a rejected API token or a rate-limited provider is always visible here.

The records table read back from the web UI on the command line showing four domains with their provider, IP version, update status and current IP, above the DDNS Updater log lines reporting the resolved public IPv4 address and each record update

Maintenance

  • Password: the web UI password is set on first boot and stored as a bcrypt entry in /etc/nginx/.ddns-updater.htpasswd. To change it, run sudo htpasswd -B /etc/nginx/.ddns-updater.htpasswd admin and then sudo systemctl reload nginx.
  • Configuration: records live in /var/lib/ddns-updater/config.json. Validate your JSON with jq . /var/lib/ddns-updater/config.json before restarting, and always confirm the service came back up afterwards - an invalid provider block makes DDNS Updater exit at start-up rather than run with a broken record.
  • Update interval: the check period, request timeouts and other runtime settings live in /etc/ddns-updater/ddns-updater.env. For example set PERIOD=1m for a faster check, then sudo systemctl restart ddns-updater. The full variable reference is in the upstream documentation.
  • Loopback binding: DDNS Updater is bound to 127.0.0.1:8000 and its health server to 127.0.0.1:9999 via LISTENING_ADDRESS and HEALTH_SERVER_ADDRESS in that same env file, so nginx is the only path in. Keep it that way - the application has no authentication of its own, so pointing it at a public interface would publish your domains and IP history to anyone who asks.
  • Restrict access: the web UI is served over plain HTTP on port 80. For production, restrict port 80 to trusted IP ranges in your Network Security Group and front it with TLS on :443 using your own domain and certificate.
  • Storage: the record configuration and the update history (updates.json) live under /var/lib/ddns-updater. Back up that directory to keep your provider settings and IP history.
  • Security patches: unattended-upgrades remains enabled so the OS continues to receive security updates automatically.

Support

cloudimg provides 24/7 expert support for this image. Contact support@cloudimg.co.uk.