Deployment guide

DDNS Updater 2.10.0

Overview

DDNS Updater is an open-source dynamic DNS client. On a schedule, it finds the machine's current public IPv4 and IPv6 address. Whenever the address changes, it calls your DNS provider's API to update the A and AAAA records you ask it to look after, so a name you own always points at the machine it runs on.

DDNS Updater supports more than fifty providers, including Cloudflare, Amazon Route 53, DuckDNS, Namecheap, GoDaddy, deSEC, Hetzner, Porkbun and No-IP, plus a generic custom HTTP provider. A web UI lists every record with its provider, IP version, last update result, current IP and previous IPs. DDNS Updater does not serve DNS itself.

On EC2, an instance keeps its public IPv4 address while it runs but gets a new one after a stop and start, unless you attach an Elastic IP. DDNS Updater keeps your record in step automatically. You can stop and start the instance, or run it on a schedule, without paying for an Elastic IP or editing DNS by hand.

This guide covers launching and verifying DDNS Updater 2.10.0 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud Hardened Pi-hole, AdGuard Home & DNS Server AMIs listing on AWS Marketplace. You pick it as the software version when you subscribe and launch. The listing's guide hub describes the other DNS servers in the listing.

The image runs DDNS Updater 2.10.0, compiled by SecureAnyCloud from the official 2.10.0 source with Go 1.26.10 and patched golang.org/x/net and go-chi/chi modules. The upstream release binary was built with an older Go toolchain that has since received security fixes. The build is reproducible, and govulncheck reports no known vulnerabilities in it. The base is a fully patched Ubuntu 24.04 LTS, and unattended security upgrades stay on for your instance.

What you get:

  • DDNS Updater 2.10.0 (ddns-updater.service) running as the unprivileged ddnsupdater user, with its web UI on 127.0.0.1:8000 and its health server on 127.0.0.1:9999 only
  • nginx publishing the web UI over HTTPS on port 443, with a certificate created on your instance's first boot, behind HTTP Basic Auth with a password unique to your instance. Port 80 redirects to HTTPS
  • An empty provider configuration at /var/lib/ddns-updater/config.json. No DNS provider token, key or example credential ships in the image
  • ddns-updater-set-hostname, a helper that serves the web UI under your own DNS name
  • The build record (BUILD-INFO.txt), go.mod, go.sum, the govulncheck result and the MIT licence in /usr/share/doc/ddns-updater/
  • Default user ubuntu, x86_64 architecture and an 8 GiB gp3 root volume

Security posture

  • Per-instance password. DDNS Updater has no login of its own, so nginx protects the whole web UI with HTTP Basic Auth for the user admin. The password is generated on each instance's first boot and stored only as a bcrypt hash in /etc/nginx/.ddns-updater.htpasswd. It is also written in plain text to /root/ddns-updater-credentials.txt (mode 0600, root only). No password or hash ships in the image.
  • Fails closed. nginx refuses to start until first boot has created the certificate and the password, so the web UI is never reachable without a login.
  • Loopback only. The app and its health server listen on 127.0.0.1 only. nginx on port 443 is the only way in.
  • HTTPS on first boot. nginx serves the UI with a TLS key and self-signed certificate created on your instance (TLS 1.2 and 1.3 only). Port 80 only redirects to HTTPS.
  • Fixed host name. The UI is served under a name taken from the instance metadata at first boot, or under one you set. The name never comes from the request. Requests for any other host name are dropped, and cross-site form posts and WebSocket connections are refused.
  • Real client addresses. nginx passes the connecting address to the app and ignores any X-Forwarded-For header a client sends.
  • Your DNS credentials stay yours. The image ships no provider configuration. The file that holds your API tokens is readable only by root and the service account.
  • No metadata access for the app. DDNS Updater calls public-IP services and your provider's API, so ddns-updater.service and nginx.service are blocked from reaching the instance metadata service (169.254.169.254 and fd00:ec2::254). Public-IP lookups, provider APIs and Amazon Time Sync still work.
  • IMDSv2 required. The AMI is registered with ImdsSupport v2.0, so instances launched from it refuse tokenless (IMDSv1) metadata requests.
  • Per-instance identity. SSH host keys and the machine ID are generated fresh on every instance.

Prerequisites

  • An AWS account and permission to launch EC2 instances
  • An EC2 key pair in the Region you launch in
  • A subnet with outbound internet access, because DDNS Updater needs to reach public-IP services and your DNS provider's API, and a public IPv4 address on the instance
  • An account with a supported DNS provider, and an API token or key that can edit the zone you want to keep updated

Recommended instance type: m5.large. DDNS Updater is a small Go process that is idle almost all the time, so t3.micro or larger is enough for it on its own.

Connecting to your instance

The DDNS Updater 2.10.0 on Ubuntu 24.04 version runs on Ubuntu 24.04 LTS. Its SSH login user is ubuntu.

1

Launch the instance

Option A: AWS Marketplace console

  • Open the SecureAnyCloud Hardened Pi-hole, AdGuard Home & DNS Server AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
  • When the subscription is active, choose Launch your software.
  • Choose One-click launch from AWS Marketplace, or Launch from EC2 Console for the full launch wizard. In Version, choose DDNS Updater 2.10.0 on Ubuntu 24.04, then choose your Region.
  • Pick an instance type and your key pair. m5.large is recommended, and the software charge is $0.04 per vCPU per hour.
  • Review the security group. The listing's default group allows SSH (22), HTTP (80) and HTTPS (443). Restrict all three to your own IP address.
  • Choose your VPC and a subnet that gives the instance a public IP. Leave the metadata defaults, because the image already requires IMDSv2. Then choose Launch.

Option B: AWS CLI

Run this on your own workstation. It creates a security group that allows SSH and the web UI from your management address only, then launches the instance.

Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and CIDR with your own:

SG=$(aws ec2 create-security-group --group-name ddns-updater \
  --description "DDNS Updater" --vpc-id vpc-0123456789abcdef0 \
  --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr <your-mgmt-cidr>
aws ec2 run-instances \
  --image-id ami-0123456789abcdef0 \
  --instance-type m5.large \
  --key-name my-key-pair \
  --security-group-ids "$SG" \
  --subnet-id subnet-0123456789abcdef0 \
  --associate-public-ip-address \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ddns-updater}]'

You do not need --metadata-options, because the AMI already requires IMDSv2.

2

Connect and read your login

Connect as ubuntu with the key pair you chose:

ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>

First boot finishes within a minute of launch. It creates the HTTPS certificate, checks that DDNS Updater answers on loopback, generates the password and starts nginx.

Your login is in a root-only file:

sudo cat /root/ddns-updater-credentials.txt
# DDNS Updater - generated on first boot by ddns-updater-firstboot.service
# These credentials are unique to this instance. Store them somewhere safe. DDNS_UPDATER_URL=https://ec2-18-206-163-80.compute-1.amazonaws.com/ DDNS_UPDATER_USERNAME=admin DDNS_UPDATER_PASSWORD=<DDNS_UPDATER_PASSWORD>
#
# Open DDNS_UPDATER_URL in a browser (self-signed certificate minted on this instance's # first boot; port 80 redirects to https) and sign in with the username and password above. # Change the name it is served as:
sudo ddns-updater-set-hostname <your-dns-name>
#
# DDNS Updater serves no DNS. It publishes THIS instance's public IP to the DNS records you # configure at your provider. The records table is empty until you add your provider settings # to /var/lib/ddns-updater/config.json and run 'sudo systemctl restart ddns-updater'. # Restrict 80/443 in the security group to your admin IPs. # Provider examples: https://github.com/qdm12/ddns-updater#configuration # Full deployment guide: https://secureanycloud.com/stacks/ddns-updater-2-10-0/

DDNS_UPDATER_PASSWORD holds your instance's own password.

3

Confirm the services are running

Confirm that first boot has finished and that DDNS Updater and nginx are active. Confirm that the binary reports v2.10.0, that the app runs as the unprivileged ddnsupdater user, and that the health endpoint answers:

systemctl is-active ddns-updater-firstboot.service ddns-updater.service nginx.service /usr/local/bin/ddns-updater --version ps -o user= -C ddns-updater curl -sk https://localhost/healthz
active active active v2.10.0 ddnsupdater ok

/healthz answers ok without a login, so you can use it for a load balancer health check over HTTPS.

The instance listens only on these ports:

sudo ss -Hltnu | awk '{print $1, $5}' | sort -u
tcp 0.0.0.0:22 tcp 0.0.0.0:443 tcp 0.0.0.0:80 tcp 127.0.0.1:8000 tcp 127.0.0.1:9999 tcp 127.0.0.53%lo:53 tcp 127.0.0.54:53 tcp [::]:22 tcp [::]:443 tcp [::]:80 udp 127.0.0.1:323 udp 127.0.0.53%lo:53 udp 127.0.0.54:53 udp 172.31.87.39%ens5:68 udp [::1]:323

The web UI (8000) and the health server (9999) listen on loopback only, and nginx publishes the UI on 443. The loopback port 53 entries are Ubuntu's local stub resolver (systemd-resolved). Port 323 is chrony's local control socket, and port 68 is the DHCP client.

The binary is the reproducible SecureAnyCloud build of the 2.10.0 source. Its record ships with the image:

sha256sum /usr/local/bin/ddns-updater grep -E '^(source|toolchain |go.mod change|binary)' /usr/share/doc/ddns-updater/BUILD-INFO.txt
8e27a6c9ef6a589494ea889535aac2e727f9d84e800f70f65ce2c2a8240d80f5 /usr/local/bin/ddns-updater source https://github.com/qdm12/ddns-updater/archive/refs/tags/v2.10.0.tar.gz source sha256 809407604d35bea7615bf02292f025ad46305b384a7c55c705410b4a1c0908a6 (tag commit 64996182d84adfeb7a873ea32f76b50918f90970) toolchain go1.26.10 linux/amd64, official tarball https://go.dev/dl/go1.26.10.linux-amd64.tar.gz toolchain sha256 ccfe7ae822d2c2f41f4fe7c956199967a4dac22d0ee0d2b2bfdb440aab3b20c5 go.mod change go get golang.org/x/net@v0.60.0 github.com/go-chi/chi/v5@v5.3.0 binary sha256 8e27a6c9ef6a589494ea889535aac2e727f9d84e800f70f65ce2c2a8240d80f5 (reproducible)
4

Sign in to the web UI

Browse to the DDNS_UPDATER_URL from Step 2, for example https://ec2-18-206-163-80.compute-1.amazonaws.com/. The certificate is self-signed, so your browser warns you once. Accept the warning, or install your own certificate (Step 8).

Without a login, nginx answers 401 and your browser asks for a username and password:

Sign in as admin with the password from Step 2. DDNS Updater opens on its records page. On a new instance the table is empty, because the image ships no provider configuration.

Once you add records (Step 6), each row shows the domain and owner, the provider, the IP version, the result of the last update, the address now published and any previous addresses. Two demonstration records that use the generic custom provider, both updated to the instance's public IP, look like this:

Port 80 only redirects to the HTTPS address:

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost/
301 https://ec2-18-206-163-80.compute-1.amazonaws.com/

You can also check the login from the command line. These requests go to nginx on the instance itself. The health endpoint is open, and the UI refuses a request with no password or a wrong one.

The instance's own password returns the DDNS Updater page. Read it from the credentials file and pass it to curl on standard input, never on the command line:

H=$(sudo sed -n 's/^set $ddns_host "\(.*\)";$/\1/p' /etc/nginx/ddns-updater-host.conf)
echo "healthz : $(curl -sk --resolve "$H:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$H/healthz")" echo "unauth : $(curl -sk --resolve "$H:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$H/")" echo "wrongpw : $(printf 'user = "admin:not-the-password"\n' | curl -K - -sk --resolve "$H:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$H/")" echo "authed : $(sudo grep '^DDNS_UPDATER_PASSWORD' /root/ddns-updater-credentials.txt | sed 's/^[^=]*=\(.*\)$/user = "admin:\1"/' | curl -K - -sk --resolve "$H:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$H/")"
sudo grep '^DDNS_UPDATER_PASSWORD' /root/ddns-updater-credentials.txt | sed 's/^[^=]*=\(.*\)$/user = "admin:\1"/' | curl -K - -sk --resolve "$H:443:127.0.0.1" "https://$H/" | grep -oE '<title>[^<]*</title>|<th>[^<]*' | head -8
healthz : 200 unauth : 401 wrongpw : 401 authed : 200 <title>DDNS Updater</title> <th>Domain <th>Owner <th>Provider <th>IP Version <th>Update Status <th>Current IP <th>Previous IPs
5

Look at the configuration file

DDNS Updater reads its records from /var/lib/ddns-updater/config.json. The image ships a valid empty configuration. Only root and the service account can read the file, because your provider API tokens will live there.

The runtime settings are in /etc/ddns-updater/ddns-updater.env:

sudo jq . /var/lib/ddns-updater/config.json
sudo stat -c '%a %U:%G %n' /var/lib/ddns-updater/config.json /etc/ddns-updater/ddns-updater.env
sudo grep -vE '^#|^$' /etc/ddns-updater/ddns-updater.env
{ "settings": [] } 640 root:ddnsupdater /var/lib/ddns-updater/config.json 640 root:ddnsupdater /etc/ddns-updater/ddns-updater.env LISTENING_ADDRESS=127.0.0.1:8000 HEALTH_SERVER_ADDRESS=127.0.0.1:9999 SERVER_ENABLED=yes ROOT_URL=/ DATADIR=/var/lib/ddns-updater CONFIG_FILEPATH=/var/lib/ddns-updater/config.json PERIOD=5m UPDATE_COOLDOWN_PERIOD=5m LOG_LEVEL=info

PERIOD=5m means DDNS Updater checks the public IP every five minutes.

6

Add your first DNS record

Edit the configuration and add one object to settings for each record. Each provider takes different fields, and the provider documentation lists them all. Use an API token that can edit only the zone it needs.

sudo nano /var/lib/ddns-updater/config.json

Here is a Cloudflare record, using an API token with Zone > DNS > Edit permission for that one zone:

{ "settings": [ { "provider": "cloudflare", "zone_identifier": "<your-zone-id>", "domain": "home.example.com", "ttl": 600, "token": "<your-api-token>", "ip_version": "ipv4" } ] }

Here is an Amazon Route 53 record. DDNS Updater takes an access key pair for Route 53. It cannot use the instance's IAM role, and the app is blocked from the metadata service anyway. Create a dedicated IAM user whose policy allows only route53:ChangeResourceRecordSets on that hosted zone:

{ "settings": [ { "provider": "route53", "domain": "home.example.com", "zone_id": "<your-hosted-zone-id>", "access_key": "<your-access-key-id>", "secret_key": "<your-secret-access-key>", "ttl": 300, "ip_version": "ipv4" } ] }

Check the JSON and keep the file's permissions. Then restart the service so that it picks up the new configuration:

sudo jq . /var/lib/ddns-updater/config.json
sudo chown root:ddnsupdater /var/lib/ddns-updater/config.json
sudo chmod 0640 /var/lib/ddns-updater/config.json
sudo systemctl restart ddns-updater systemctl is-active ddns-updater

Check the service after every change. DDNS Updater validates every record at start-up and exits if one is malformed, instead of running with a broken record.

If systemctl is-active reports anything other than active, read the reason with sudo journalctl -u ddns-updater -n 20 (Step 7). Fix the entry and restart again.

7

Force an update and read the log

You do not have to wait for the next check. The /update page forces an immediate refresh of every record. After you sign in, open https://<your-host>/update in a browser:

From the shell, the request returns HTTP 202 and how long the refresh took:

H=$(sudo sed -n 's/^set $ddns_host "\(.*\)";$/\1/p' /etc/nginx/ddns-updater-host.conf)
sudo grep '^DDNS_UPDATER_PASSWORD' /root/ddns-updater-credentials.txt | sed 's/^[^=]*=\(.*\)$/user = "admin:\1"/' | curl -K - -sk --resolve "$H:443:127.0.0.1" -w '\nHTTP %{http_code}\n' "https://$H/update"
All records updated successfully in 22.628µs HTTP 202

The log shows the public IP that DDNS Updater found, the address each domain resolves to now, every update it sent, and any error from your provider, such as a rejected token:

sudo journalctl -u ddns-updater -n 6 --no-pager -o cat
2026-10-20T16:57:23Z INFO [healthcheck server] health http server listening on 127.0.0.1:9999 2026-10-20T16:57:23Z INFO [http server] http server listening on 127.0.0.1:8000 2026-10-20T16:57:23Z INFO [backup] disabled 2026/10/20 16:58:02 "GET http://127.0.0.1:8000/ HTTP/1.1" from 127.0.0.1 - 200 1907B in 48.225µs 2026/10/20 16:58:02 "GET http://127.0.0.1:8000/ HTTP/1.1" from 127.0.0.1 - 200 1907B in 20.916µs 2026/10/20 16:58:02 "GET http://127.0.0.1:8000/update HTTP/1.1" from 127.0.0.1 - 202 45B in 31.969µs

With records configured, lines such as ipv4 address of home.example.com is <none> and your ipv4 address is 18.206.163.80 and Updating record [domain: example.com | owner: home | provider: cloudflare | ip: ipv4] to use 18.206.163.80 appear in the log.

8

HTTPS certificate and host name

The certificate was created on your instance's first boot for the instance's public DNS name, public IP, private IP and localhost. The key, the password hash and the credentials file are readable only by root, except that nginx's group can read the hash:

sudo openssl x509 -in /etc/ssl/ddns-updater/ddns-updater.crt -noout -subject -dates -ext subjectAltName
sudo stat -c '%a %U:%G %n' /etc/ssl/ddns-updater/ddns-updater.key /etc/ssl/ddns-updater/ddns-updater.crt /etc/nginx/.ddns-updater.htpasswd /root/ddns-updater-credentials.txt
subject=CN = ec2-18-206-163-80.compute-1.amazonaws.com, O = sac notBefore=Oct 20 16:54:32 2026 GMT notAfter=Jan 22 16:54:32 2029 GMT X509v3 Subject Alternative Name: DNS:ec2-18-206-163-80.compute-1.amazonaws.com, IP Address:18.206.163.80, IP Address:172.31.87.39, IP Address:127.0.0.1, DNS:localhost 600 root:root /etc/ssl/ddns-updater/ddns-updater.key 644 root:root /etc/ssl/ddns-updater/ddns-updater.crt 640 root:www-data /etc/nginx/.ddns-updater.htpasswd 600 root:root /root/ddns-updater-credentials.txt

To serve the UI under your own DNS name, for example the name DDNS Updater keeps pointed at this instance, run the helper. It records the name, creates a new self-signed certificate for it, updates nginx and rewrites the URL in the credentials file:

sudo ddns-updater-set-hostname ddns.example.com

If the instance's public address changes after a stop and start, run the helper again so that the certificate names the new address too.

To use a certificate from your own certificate authority, replace /etc/ssl/ddns-updater/ddns-updater.key (keep it 0600 root) and /etc/ssl/ddns-updater/ddns-updater.crt. Then run sudo systemctl reload nginx.

9

Metadata access is blocked for the app

ddns-updater.service and nginx.service carry a systemd IPAddressDeny rule for the instance metadata addresses. Nothing running inside them can reach the metadata service, even if a record's URL pointed at it.

Each entry is a single address, so the Amazon Time Sync Service at 169.254.169.123 keeps working, as chrony shows:

systemctl show -p IPAddressDeny ddns-updater.service nginx.service chronyc -n sources | grep 169.254
IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 ^* 169.254.169.123 3 4 377 9 -13us[ -16us] +/- 544us

Maintenance

  • Password: the per-instance password is in /root/ddns-updater-credentials.txt, and its bcrypt hash is in /etc/nginx/.ddns-updater.htpasswd. To change it, run sudo htpasswd -B /etc/nginx/.ddns-updater.htpasswd admin, which prompts for the new password. Then run sudo systemctl reload nginx and update the credentials file.
  • Records: after you edit /var/lib/ddns-updater/config.json, check it with sudo jq . and keep it 0640 root:ddnsupdater. Then run sudo systemctl restart ddns-updater and confirm that the service is active.
  • Check interval: PERIOD and the other runtime settings are in /etc/ddns-updater/ddns-updater.env (for example PERIOD=1m). After you change them, run sudo systemctl restart ddns-updater. The upstream documentation has the full list.
  • Keep the app on loopback: leave LISTENING_ADDRESS=127.0.0.1:8000 and HEALTH_SERVER_ADDRESS=127.0.0.1:9999 as they are. The app has no authentication of its own, so binding it to a public address would publish your domains and IP history to anyone.
  • Backups: the configuration and the update history (updates.json) live under /var/lib/ddns-updater. Back up that directory. It contains your provider tokens, so store the backup securely.
  • Upgrades: to move to a newer DDNS Updater release, launch the newer version from the listing when it is published, and copy your config.json across.
  • Security patches: unattended-upgrades stays enabled, so the OS keeps receiving security updates.

Security recommendations

  • Keep the security group tight. Open 22, 80 and 443 only to your management address. DDNS Updater needs no inbound access to do its job.
  • Scope your provider tokens to the one zone (or record) they update, and rotate them if the instance is ever shared.
  • Use your own certificate for the UI if other people sign in to it, so that browsers trust it without a warning.

Support and licensing

DDNS Updater is developed by Quentin McGaw and contributors and distributed under the MIT License. This image ships a binary that SecureAnyCloud compiled from the unmodified 2.10.0 source with Go 1.26.10 and two patched Go modules; the build record and licence are in /usr/share/doc/ddns-updater/. SecureAnyCloud provides the packaging, the hardening, first-boot HTTPS and per-instance credentials, and 24/7 support.

SecureAnyCloud is not affiliated with or endorsed by the DDNS Updater project or any DNS provider. Product and provider names are marks of their owners and are used here only to identify the software.

For help with deployment or configuration, email sales@secureanycloud.com or visit SecureAnyCloud support.

Deploy on AWS

Launch DDNS Updater 2.10.0 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS