If you run a server from your home network, or want to access your home computer remotely, one challenge is tracking your public IP address, as most domestic internet connections use a dynamic IP that changes periodically. A Dynamic DNS (DDNS) service solves this by keeping a DNS record up to date automatically.
This script uses the Cloudflare API (v4) to act as a DDNS client. It detects your current public IP address and updates a Cloudflare DNS A record whenever a change is detected. It runs well on a Raspberry Pi, Ubuntu, Debian, or any modern Linux system with the prerequisites installed.
Only two tools are required:
| Tool | Purpose |
|---|---|
| curl | HTTP requests (public IP lookup and Cloudflare API) |
| jq | JSON parsing (config file and API responses) |
On Debian / Ubuntu:
sudo apt install curl jqNote:
digis no longer required. The current DNS record value is now fetched directly from the Cloudflare API, which is more accurate than a DNS lookup and eliminates DNS propagation lag.
Download the script into ~/.local/bin and make it executable in one step.
No sudo is required:
mkdir -p ~/.local/bin \
&& curl -fsSL \
https://raw.githubusercontent.com/paulgit/cloudflare-ddns/master/cloudflare-ddns \
-o ~/.local/bin/cloudflare-ddns \
&& chmod +x ~/.local/bin/cloudflare-ddnsNote: Inspect the script before running it if you prefer:
curl -fsSL https://raw.githubusercontent.com/paulgit/cloudflare-ddns/master/cloudflare-ddns | less
~/.local/bin is on $PATH by default on most modern Linux distributions
(Ubuntu 20.04+, Fedora, Arch, Raspberry Pi OS). If cloudflare-ddns is not
found after installation, add the directory to your shell profile:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcClone the repository and symlink (or copy) the script into your PATH:
git clone https://github.com/paulgit/cloudflare-ddns.git
mkdir -p ~/.local/bin
ln -s "$(pwd)/cloudflare-ddns/cloudflare-ddns" ~/.local/bin/cloudflare-ddnsOr copy it instead of symlinking:
git clone https://github.com/paulgit/cloudflare-ddns.git
mkdir -p ~/.local/bin
cp cloudflare-ddns/cloudflare-ddns ~/.local/bin/cloudflare-ddns
chmod +x ~/.local/bin/cloudflare-ddnscloudflare-ddns --versionConfiguration is stored as a JSON file. The default location is:
~/.config/cloudflare-ddns/cloudflare-ddns.json
This respects the XDG Base Directory Specification.
If the environment variable $XDG_CONFIG_HOME is set, it is used as the base
instead of ~/.config.
A different path can be specified at runtime with the --config flag (see
Command-Line Options below).
Run the script once without a config file and it will create a template automatically:
./cloudflare-ddnsThe template will be written to the default location with permissions 600.
Open it in your editor and fill in your values:
{
"auth_token": "YOUR_CLOUDFLARE_API_TOKEN",
"zone_name": "example.com",
"record_name": "ddns.example.com",
"ip_check_urls": [
"https://ipv4.icanhazip.com",
"https://api.ipify.org"
]
}| Field | Required | Description |
|---|---|---|
auth_token |
Yes* | Cloudflare API Token (recommended) |
auth_email |
Yes* | Account e-mail for legacy Global API Key auth |
auth_key |
Yes* | Global API Key for legacy auth |
zone_name |
Yes | The apex domain managed in Cloudflare (e.g. example.com) |
record_name |
Yes | The A record to keep updated (e.g. ddns.example.com) |
ip_check_urls |
Yes | Ordered array of URLs that return the public IPv4 address as plain text; the script tries each in sequence until one succeeds |
* Provide either auth_token or both auth_email + auth_key.
ip_check_urls is evaluated from top to bottom. If a URL is unreachable,
times out, or returns an invalid value, the script automatically tries the
next URL.
For backward compatibility, existing configs that still use
ip_check_url (single string) continue to work. A warning is emitted so you
can migrate to ip_check_urls in your next config update.
Create a scoped API Token at https://dash.cloudflare.com/profile/api-tokens with the following permissions:
- Zone / DNS / Edit — scoped to the specific zone you want to update
A scoped token limits the blast radius if the credential is ever leaked; it can only edit DNS records in the zones you select.
{
"auth_token": "your-api-token-here",
"zone_name": "example.com",
"record_name": "ddns.example.com",
"ip_check_urls": [
"https://ipv4.icanhazip.com",
"https://api.ipify.org"
]
}The Global API Key grants full access to your entire Cloudflare account. Use this only if you cannot use API Tokens. The script will print a deprecation warning each time it runs.
{
"auth_email": "you@example.com",
"auth_key": "your-global-api-key-here",
"zone_name": "example.com",
"record_name": "ddns.example.com",
"ip_check_urls": [
"https://ipv4.icanhazip.com",
"https://api.ipify.org"
]
}Usage: cloudflare-ddns [OPTIONS]
Options:
-h, --help Show this help message and exit.
--version Print the version number and exit.
--config PATH Path to the JSON configuration file.
Default: ~/.config/cloudflare-ddns/
cloudflare-ddns.json
--dry-run Run the full check (load config, auth,
fetch public IP, read current DNS IP)
but do not apply any DNS changes.
Logs and prints what would have changed.
--force Skip the DNS value check and update the
record immediately to the current public
IP. Log output clearly states the update
was forced. Compatible with --dry-run.
--no-color, --cron-mode Disable coloured output. Colour is
also suppressed automatically when
not attached to a terminal or when
the NO_COLOR env var is set.
Use a custom config file:
./cloudflare-ddns --config /etc/cloudflare-ddns/config.jsonCheck what would happen without making any changes:
./cloudflare-ddns --dry-runForce an immediate DNS update, skipping the current value check:
./cloudflare-ddns --forceCombine --force with --dry-run to see what a forced update would do
without actually applying it:
./cloudflare-ddns --force --dry-runCombine with a custom config and no colour:
./cloudflare-ddns --dry-run --config /etc/cloudflare-ddns/config.json --no-colorDisable colour output explicitly:
./cloudflare-ddns --no-colorThe script stores runtime data separately from the configuration:
| Path | Purpose |
|---|---|
~/.local/state/cloudflare-ddns/cloudflare.ids |
Cached Cloudflare zone and record identifiers |
~/.local/state/cloudflare-ddns/cloudflare.log |
Timestamped log of all INFO / WARN / ERROR events |
The ~/.local/state/cloudflare-ddns/ directory is created automatically with permissions
700 on first run.
On the first successful run the script fetches your zone and record
identifiers from the Cloudflare API and stores them in cloudflare.ids. On
subsequent runs these are loaded from the cache, avoiding an unnecessary API
call. The cache is regenerated automatically if it is deleted or found to be
incomplete.
All events are appended to cloudflare.log with UTC ISO-8601 timestamps,
regardless of whether the terminal is a TTY. This makes the log useful when
the script is invoked from cron. Example entries:
2024-06-01T08:00:01Z [INFO] ddns.example.com: 1.2.3.4 -> 5.6.7.8
2024-06-01T08:05:01Z [WARN] ID cache missing or incomplete; fetching from API.
2024-06-01T09:10:01Z [ERROR] Public IP check returned invalid value: ''.
2024-06-01T10:00:01Z [INFO] [FORCED] ddns.example.com: updated to 5.6.7.8 (DNS check skipped)
When --dry-run and --force are combined, the entry is prefixed with both
labels:
2024-06-01T10:00:01Z [INFO] [DRY-RUN] [FORCED] Would update ddns.example.com to 5.6.7.8 (DNS check skipped)
Add an entry to your crontab to run the script periodically. A check every five minutes is a reasonable starting point:
crontab -e*/5 * * * * ~/.local/bin/cloudflare-ddns --cron-mode
The --cron-mode flag (equivalent to --no-color) prevents ANSI colour
escape codes from appearing in cron mail. Colour is also disabled
automatically when the script detects it is not attached to a terminal, so
the flag is optional but recommended for clarity.
Note: cron does not expand
~in command paths on all systems. Use the full path (/home/youruser/.local/bin/cloudflare-ddns) or the$HOMEvariable if~does not work in your crontab.
If you use a non-default config location, pass --config as well:
*/5 * * * * ~/.local/bin/cloudflare-ddns --cron-mode --config ~/.config/cloudflare-ddns/alt.json
The script produces no output when the IP address has not changed, so cron will not send mail on the majority of runs. Output (and therefore mail) is only generated when the record is updated, a warning is raised, or an error occurs.
Note: When
--forceis used, the DNS record is always updated and output is always produced, regardless of whether the IP has changed. Avoid using--forcein cron unless you specifically want this behaviour.
A per-user lock directory is created at /tmp/cloudflare-ddns-<uid>.lock
before any network activity begins. This prevents two cron instances from
running simultaneously if a previous invocation is still in progress (e.g.
due to a slow network). The lock is removed automatically on exit, including
on error.
Colour is enabled automatically when the script's output is connected to a terminal (TTY). It is suppressed in any of the following cases:
- The
NO_COLORenvironment variable is set (any value) — see https://no-color.org/ - The
--no-coloror--cron-modeflag is passed - stdout and stderr are both redirected (e.g. cron, pipes)
- The config file and identifier cache are both created and maintained with
permissions
600(owner read/write only). - The config directory (
~/.config/cloudflare-ddns/) and data directory (~/.local/state/cloudflare-ddns/) are maintained with permissions700. - The config file is parsed as JSON via
jq— it is never executed as shell code. - API credentials are never written to the log file.
- Use an API Token (Option A) rather than the Global API Key: a token can be revoked independently and grants only the minimum required permission.
| Symptom | Likely cause |
|---|---|
'jq' is required but not installed |
Install jq: sudo apt install jq |
Config file contains invalid JSON |
Syntax error in your config file — validate with jq . ~/.config/cloudflare-ddns/cloudflare-ddns.json |
No valid auth found |
auth_token (or auth_email/auth_key) still contains the placeholder value |
Zone '…' not found |
zone_name does not match any zone in your Cloudflare account |
Record '…' not found |
The A record does not exist in Cloudflare yet — create it manually first |
Another instance is already running |
A previous run is still active; or the lock was left behind — remove /tmp/cloudflare-ddns-<uid>.lock |
Failed to get public IP |
The ip_check_url is unreachable; try curl https://ipv4.icanhazip.com manually |
| DNS record keeps updating unexpectedly | Run with --dry-run to inspect what public IP and DNS IP are being detected without making changes |
| Need to force an update regardless of current DNS value | Use --force to skip the DNS value check and update immediately; combine with --dry-run to preview the action first |
Thanks to teddysun and others whose scripts provided inspiration.
Written by Paul Git and Claude AI.
No warranties are given for correct function. Use at your own risk.