A lightweight bash script that acts as a DDNS client for Azure DNS. It detects the public IP address of the network via an external service and updates an A record in Azure DNS via the REST API. Designed to run as a cron job on a Raspberry Pi with minimal dependencies (bash, curl, jq).
- Automatic IP detection with fallback (icanhazip.com -> checkip.amazonaws.com)
- Smart update: only PUT to Azure DNS when the IP has actually changed
- Lock file (
/tmp/azure-ddns.lock) prevents concurrent runs viaflock - Force mode (
--force) to update regardless of IP change - Debug mode (
VERBOSE=1) for detailed logging - Configurable TTL (
DNS_TTL, default 300 seconds) - Exit codes for structured error handling (0-4)
- Install dependencies:
sudo apt-get install jq - Download the script and make it executable (see Installation)
- Create an Azure Service Principal (see Configuration)
- Set the environment variables (see Environment variables)
- Test:
./azure-ddns - Set up a cron job (see Setting up a cron job)
| Dependency | Minimum version | Check command | Default on Pi? |
|---|---|---|---|
| bash | >= 4.x | bash --version |
Yes |
| curl | >= 7.68 | curl --version |
Yes |
| jq | >= 1.6 | jq --version |
No |
Install jq (the only non-default dependency):
sudo apt-get install jq# Download the script
sudo curl -o /usr/local/bin/azure-ddns \
https://raw.githubusercontent.com/geertvanzoest/azure-ddns/main/azure-ddns
# Make executable
sudo chmod +x /usr/local/bin/azure-ddns
# Verify
azure-ddns --help || echo "Installed at $(which azure-ddns)"azure-ddns requires an Azure Service Principal with minimal permissions on the DNS zone. Follow these steps using the Azure CLI (az):
# 1. Create app registration
az ad app create --display-name "azure-ddns"
# Note the appId from the output -> this becomes AZURE_CLIENT_ID# 2. Create Service Principal
az ad sp create --id <APP_ID># 3. Generate client secret
az ad app credential reset --id <APP_ID> --display-name "azure-ddns-secret"
# Note password from the output -> this becomes AZURE_CLIENT_SECRET
# Note tenant from the output -> this becomes AZURE_TENANT_ID# 4. Assign DNS Zone Contributor role (scoped to zone level)
az role assignment create \
--assignee <APP_ID> \
--role "DNS Zone Contributor" \
--scope "/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Network/dnsZones/<ZONE_NAME>"The scope is intentionally limited to the specific DNS zone. The Service Principal only receives permissions on that single zone, not on the entire subscription or resource group.
| Variable | Required | Description | Example |
|---|---|---|---|
AZURE_TENANT_ID |
Yes | Microsoft Entra tenant GUID | aaaabbbb-0000-cccc-1111-dddd2222eeee |
AZURE_CLIENT_ID |
Yes | Service Principal application ID | 11112222-bbbb-3333-cccc-4444dddd5555 |
AZURE_CLIENT_SECRET |
Yes | Service Principal secret | A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u |
AZURE_SUBSCRIPTION_ID |
Yes | Azure subscription GUID | 00000000-0000-0000-0000-000000000000 |
AZURE_RESOURCE_GROUP |
Yes | Resource group of the DNS zone | rg-dns |
DNS_ZONE_NAME |
Yes | DNS zone name (without trailing dot) | example.com |
DNS_RECORD_NAME |
Yes | Relative record name | home (results in home.example.com) |
DNS_TTL |
No | TTL in seconds (default: 300) | 300 |
Method 1: Via /etc/environment (persistent, all users)
Add the variables to /etc/environment:
sudo tee -a /etc/environment << 'EOF'
AZURE_TENANT_ID=<YOUR_TENANT_ID>
AZURE_CLIENT_ID=<YOUR_CLIENT_ID>
AZURE_CLIENT_SECRET=<YOUR_CLIENT_SECRET>
AZURE_SUBSCRIPTION_ID=<YOUR_SUBSCRIPTION_ID>
AZURE_RESOURCE_GROUP=<YOUR_RESOURCE_GROUP>
DNS_ZONE_NAME=<YOUR_ZONE>
DNS_RECORD_NAME=<YOUR_RECORD>
EOFMethod 2: Via crontab (cron only, see Setting up a cron job)
The variables are set directly in the crontab entry. See the example below.
Warning: NEVER store secrets in the script itself. Always use environment variables.
# Normal (only updates if IP has changed)
./azure-ddns
# Force update (always update, regardless of IP change)
./azure-ddns --force
# Debug mode (detailed logging)
VERBOSE=1 ./azure-ddns
# Combination: force update with debug output
VERBOSE=1 ./azure-ddns --forceOpen the crontab:
crontab -eAdd an entry to run the script every 5 minutes:
*/5 * * * * AZURE_TENANT_ID=xxx AZURE_CLIENT_ID=xxx AZURE_CLIENT_SECRET=xxx AZURE_SUBSCRIPTION_ID=xxx AZURE_RESOURCE_GROUP=xxx DNS_ZONE_NAME=xxx DNS_RECORD_NAME=xxx /usr/local/bin/azure-ddns >> /var/log/azure-ddns.log 2>&1If the environment variables are already in /etc/environment, this suffices:
*/5 * * * * /usr/local/bin/azure-ddns >> /var/log/azure-ddns.log 2>&1Output is written to /var/log/azure-ddns.log for troubleshooting. Create the log file if it doesn't exist yet:
sudo touch /var/log/azure-ddns.log
sudo chown $(whoami) /var/log/azure-ddns.logThe script uses /tmp/azure-ddns.lock via flock to prevent two instances from running simultaneously. If a previous run is still in progress, the new run is skipped with the message "Another instance is running, skipped". No user action is required.
| Code | Constant | Meaning | Possible causes | Solution |
|---|---|---|---|---|
| 0 | EXIT_OK | Success | - | No action needed |
| 1 | EXIT_CONFIG | Configuration error | Missing env var, jq not installed | Check all required env vars, install jq |
| 2 | EXIT_IP | IP detection failed | No internet, IP services unreachable | Check internet connection, test curl https://icanhazip.com |
| 3 | EXIT_AUTH | Authentication failed | Invalid credentials, expired secret, wrong tenant | Check AZURE_TENANT_ID/CLIENT_ID/CLIENT_SECRET, renew secret |
| 4 | EXIT_DNS | DNS operation failed | Insufficient permissions, wrong zone/record name | Check RBAC role, resource group, zone name |
Check the exit code after a run:
./azure-ddns; echo "Exit code: $?"Use VERBOSE=1 for detailed logging:
VERBOSE=1 ./azure-ddnsThis shows:
- Which configuration is loaded
- OAuth2 token status
- Which IP service is used and the detected IP
- Comparison of current DNS record with the new IP
- DNS update payload
"Another instance is running, skipped"
A previous run is still in progress. Wait for it to finish. Check if there's an active process:
ps aux | grep azure-ddnsIf no process is running but the message persists, restart the Pi or wait until the next reboot (flock is automatically released when the process ends).
HTTP 401 on token request (exit code 3)
The client secret has expired or is incorrect. Generate a new secret:
az ad app credential reset --id <APP_ID> --display-name "azure-ddns-secret"Then update AZURE_CLIENT_SECRET in the environment variables.
HTTP 403 on DNS update (exit code 4)
The DNS Zone Contributor role is not (correctly) assigned. Check the role assignment:
az role assignment list \
--assignee <APP_ID> \
--scope "/subscriptions/<SUB_ID>/resourceGroups/<RG>/providers/Microsoft.Network/dnsZones/<ZONE>"Reassign the role if it's missing (see Creating an Azure Service Principal).
No IP detected (exit code 2)
Check if the Pi can send outbound HTTPS traffic:
curl -s https://icanhazip.com
curl -s https://checkip.amazonaws.comIf both fail, check the internet connection and any firewall rules.
azure-ddns tries the following services to detect the public IP address:
| Order | Service | URL | Owner |
|---|---|---|---|
| 1 (primary) | icanhazip.com | https://icanhazip.com |
Cloudflare |
| 2 (fallback) | checkip.amazonaws.com | https://checkip.amazonaws.com |
Amazon AWS |
If the primary service is unreachable, the fallback is used automatically.
- Azure DNS REST API version: 2018-05-01 (current stable GA release)
- OAuth2 flow: Client credentials grant via Microsoft Entra
- Token endpoint:
https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token - IP validation: Strict IPv4 regex validation on IP service responses