DDNS Updater on Ubuntu 24.04 on Azure User Guide
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-updatersystemd service - The DDNS Updater web UI on
:80fronted 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
ddnsupdatersystem 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
/healthzendpoint for Azure Load Balancer health probes ddns-updater.service+nginx.serviceas 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.

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.

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 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:

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.

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:

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:

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.

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, runsudo htpasswd -B /etc/nginx/.ddns-updater.htpasswd adminand thensudo systemctl reload nginx. - Configuration: records live in
/var/lib/ddns-updater/config.json. Validate your JSON withjq . /var/lib/ddns-updater/config.jsonbefore 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 setPERIOD=1mfor a faster check, thensudo systemctl restart ddns-updater. The full variable reference is in the upstream documentation. - Loopback binding: DDNS Updater is bound to
127.0.0.1:8000and its health server to127.0.0.1:9999viaLISTENING_ADDRESSandHEALTH_SERVER_ADDRESSin 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
:443using 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.