DDNS Updater 2.10.0 on AWS User Guide
Overview
This guide covers launching and verifying DDNS Updater 2.10.0 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg 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 other DNS servers in the listing are described on the listing's guide hub.
DDNS Updater is an open source dynamic DNS client. On a schedule it finds the machine's current public IPv4 and IPv6 address and, whenever it changes, 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. It 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, so you can stop and start the instance (or run it on a schedule) without paying for an Elastic IP or editing DNS by hand.
The image runs DDNS Updater 2.10.0 compiled by cloudimg from the official 2.10.0 source with Go 1.26.10 and patched golang.org/x/net and go-chi/chi modules, because 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 is on the image:
- DDNS Updater 2.10.0 (
ddns-updater.service) running as the unprivilegedddnsupdateruser, with its web UI on127.0.0.1:8000and its health server on127.0.0.1:9999only - 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 to serve the web UI under your own DNS name- The build record (
BUILD-INFO.txt),go.mod,go.sum, thegovulncheckresult and the MIT licence in/usr/share/doc/ddns-updater/
Default user: ubuntu · Architecture: x86_64 · Root volume: 8 GiB gp3
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, stored only as a bcrypt hash in/etc/nginx/.ddns-updater.htpasswd, and 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.1only. nginx on 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 one you set), never 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-Forheader a client sends. - Your DNS credentials stay yours. The image ships no provider configuration. The file you put your API tokens in 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.serviceandnginx.serviceare 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 token-less (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 (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
| Version | Operating system | SSH user |
|---|---|---|
| DDNS Updater 2.10.0 on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened Pi-hole, AdGuard Home & DNS Server AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once 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 (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - 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 (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, and 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: the AMI already makes IMDSv2 required.
Step 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://www.cloudimg.co.uk/guides/ddns-updater-2-10-0-on-ubuntu-24-04-aws/
DDNS_UPDATER_PASSWORD holds your instance's own password.
Step 3: Confirm the services are running
First boot, DDNS Updater and nginx are active, the binary reports v2.10.0, the app runs as the unprivileged ddnsupdater user, and 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.
These are the only ports the instance listens on:
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; nginx publishes the UI on 443. The loopback port 53 entries are Ubuntu's local stub resolver (systemd-resolved), 323 is chrony's local control socket and 68 is the DHCP client.
The binary is the reproducible cloudimg 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)
Step 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 once; accept it, 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. Here are two demonstration records that use the generic custom provider, both updated to the instance's public IP:

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 check the login from the command line too. These requests go to nginx on the instance itself: the health endpoint is open, the UI refuses a request with no password or a wrong one, and the instance's own password (read from the credentials file and passed to curl on standard input, never on the command line) returns the DDNS Updater page:
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
Step 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, and the file is readable only by root and the service account, because it is where your provider API tokens will live. 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.
Step 6: Add your first DNS record
Edit the configuration and add one object to settings for each record. Each provider takes different fields; 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
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"
}
]
}
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), so 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, keep the file's permissions, then restart the service so 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.
Step 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. In a browser, open https://<your-host>/update after signing in:

From the shell it 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 DDNS Updater found, the address each domain resolves to now, every update it sent, and any error from your provider (for example 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 here.
Step 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 (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 = cloudimg
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 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.
Step 9: Metadata access is blocked for the app
ddns-updater.service and nginx.service carry a systemd IPAddressDeny rule for the instance metadata addresses, so 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.txtand its bcrypt hash in/etc/nginx/.ddns-updater.htpasswd. To change it, runsudo htpasswd -B /etc/nginx/.ddns-updater.htpasswd admin(it prompts for the new password), thensudo systemctl reload nginxand update the credentials file. - Records: after editing
/var/lib/ddns-updater/config.json, check it withsudo jq ., keep it0640 root:ddnsupdater, runsudo systemctl restart ddns-updaterand confirm the service is active. - Check interval:
PERIODand the other runtime settings are in/etc/ddns-updater/ddns-updater.env(for examplePERIOD=1m), followed bysudo systemctl restart ddns-updater. The full list is in the upstream documentation. - Keep the app on loopback: leave
LISTENING_ADDRESS=127.0.0.1:8000andHEALTH_SERVER_ADDRESS=127.0.0.1:9999as 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.jsonacross. - Security patches: unattended-upgrades remains 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 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 cloudimg 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/. cloudimg provides the packaging, the hardening, first-boot HTTPS and per-instance credentials, and 24/7 support.
cloudimg 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 support@cloudimg.co.uk or visit cloudimg support.