Building a Small Cloudflare Dynamic DNS Updater in Go

This post walks through a complete, working Dynamic DNS updater that talks to Cloudflare. It covers the Go program, the configuration format, a systemd unit, release packaging with checksums, and an installer script that fetches everything and gets it running. The goal is a minimal, auditable setup that you can keep in a public repository and deploy in seconds on a fresh machine.
The full project, including installer script, systemd unit, and release assets, is available on GitHub: ab-a/cf-ddns.
Why DDNS
Most home or edge environments run behind dynamic public addresses. When the IP changes, a DNS record must be updated or your hostname goes stale. The program below polls for the current public IPv4 address and updates a Cloudflare A record only when needed. It avoids heavy dependencies and is easy to reason about.
Why this project exists
I couldn’t find a DDNS client that hit the right balance. Most were heavy, containerized, or overcomplicated for what should be a single binary. I wanted something light, dependency-free, and easy to install and manage on any Linux host.
This project installs cleanly without Docker, lets you choose a specific network interface, and handles NAT properly by calling an external API to discover your public IP instead of trusting local interfaces.
Design overview
- Detect the public IPv4 using a simple HTTP call to an API like https://api.ipify.org that returns a single address in the body.
- Look up the Cloudflare zone id for the configured zone.
- Query the DNS record by name.
- Create or update the A record only if TTL, proxied flag or content changed.
- Repeat on a configurable interval.
- Bind outbound HTTP sockets to a specific interface using SO_BINDTODEVICE on Linux when desired.
- Exit gracefully on SIGINT or SIGTERM.
The configuration file
The program reads a YAML configuration. The default path is /etc/cf-ddns/config.yaml
, overridable via CF_DDNS_CONFIG
.
interface: "eth1"
ip_api: "https://api.ipify.org"
interval: "5m"
cloudflare:
api_token: "CHANGE_ME_cloudflare_api_token"
zone_name: "example.com"
record_name: "ddns.example.com"
ttl: 1
proxied: false
Interface selects the egress device for HTTP calls. The IP API must return the IPv4 as plain text. The interval uses Go duration syntax. Cloudflare credentials need the least privilege that works: Zone Read and DNS Edit on the target zone.
The Go program
Below is the full program exactly as used. It is a single file that compiles to a static binary with a tiny dependency graph. It uses the official Cloudflare v4 HTTP endpoints.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Interface string `yaml:"interface"`
IPAPI string `yaml:"ip_api"`
Interval string `yaml:"interval"`
Cloudflare struct {
APIToken string `yaml:"api_token"`
ZoneName string `yaml:"zone_name"`
RecordName string `yaml:"record_name"`
TTL int `yaml:"ttl"`
Proxied bool `yaml:"proxied"`
} `yaml:"cloudflare"`
}
type cfZoneResp struct {
Success bool `json:"success"`
Errors []any `json:"errors"`
Result []cfZoneObj `json:"result"`
}
type cfZoneObj struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cfRecordQueryResp struct {
Success bool `json:"success"`
Errors []any `json:"errors"`
Result []cfDNSRecord `json:"result"`
}
type cfDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfRecordWriteReq struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfRecordWriteResp struct {
Success bool `json:"success"`
Errors []any `json:"errors"`
Result cfDNSRecord `json:"result"`
}
func must[T any](v T, err error) T {
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
os.Exit(1)
}
return v
}
func loadConfig(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, err
}
if c.Interface == "" || c.IPAPI == "" || c.Interval == "" ||
c.Cloudflare.APIToken == "" || c.Cloudflare.ZoneName == "" || c.Cloudflare.RecordName == "" {
return nil, errors.New("missing required config fields")
}
return &c, nil
}
// httpClientOnInterface returns an http.Client whose TCP sockets are bound to a given interface (Linux).
// This uses SO_BINDTODEVICE and requires CAP_NET_RAW or root.
func httpClientOnInterface(iface string, timeout time.Duration) *http.Client {
dialer := &net.Dialer{
Timeout: timeout,
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
// Bind the socket to the interface (Linux only)
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: timeout,
KeepAlive: 30 * time.Second,
Control: func(network, address string, c syscall.RawConn) error {
var sockErr error
control := func(fd uintptr) {
// SO_BINDTODEVICE
ifaceBytes := []byte(iface + "\x00")
sockErr = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, string(ifaceBytes[:len(ifaceBytes)-1]))
}
if err := c.Control(control); err != nil {
return err
}
return sockErr
},
}
return d.DialContext(ctx, network, address)
}
return &http.Client{
Transport: transport,
Timeout: timeout,
}
}
func fetchPublicIPv4(cli *http.Client, apiURL string) (string, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
resp, err := cli.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("non-2xx from IP API: %s", resp.Status)
}
body, _ := io.ReadAll(resp.Body)
ip := strings.TrimSpace(string(body))
// Basic sanity
parsed := net.ParseIP(ip)
if parsed == nil || parsed.To4() == nil {
return "", fmt.Errorf("IP API did not return an IPv4 address: %q", ip)
}
return ip, nil
}
func cfGetZoneID(ctx context.Context, httpc *http.Client, token, zoneName string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var zr cfZoneResp
if err := json.NewDecoder(resp.Body).Decode(&zr); err != nil {
return "", err
}
if !zr.Success || len(zr.Result) == 0 {
return "", fmt.Errorf("zone lookup failed for %s", zoneName)
}
return zr.Result[0].ID, nil
}
func cfGetRecord(ctx context.Context, httpc *http.Client, token, zoneID, fqdn string) (*cfDNSRecord, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s", zoneID, fqdn),
nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var rr cfRecordQueryResp
if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil {
return nil, err
}
if !rr.Success {
return nil, errors.New("record query failed")
}
if len(rr.Result) == 0 {
return nil, nil
}
return &rr.Result[0], nil
}
func cfUpsertA(ctx context.Context, httpc *http.Client, token, zoneID, fqdn, ip string, ttl int, proxied bool, existing *cfDNSRecord) error {
body := cfRecordWriteReq{
Type: "A",
Name: fqdn,
Content: ip,
TTL: ttl,
Proxied: proxied,
}
var method, url string
if existing == nil {
method = "POST"
url = fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
} else {
// No change?
if existing.Content == ip && existing.TTL == ttl && existing.Proxied == proxied {
return nil
}
method = "PUT"
url = fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, existing.ID)
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, method, url, strings.NewReader(string(payload)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var wr cfRecordWriteResp
if err := json.NewDecoder(resp.Body).Decode(&wr); err != nil {
return err
}
if !wr.Success {
return errors.New("Cloudflare write failed")
}
return nil
}
func runOnce(ctx context.Context, cfg *Config, httpc *http.Client) error {
ip, err := fetchPublicIPv4(httpc, cfg.IPAPI)
if err != nil {
return fmt.Errorf("failed to detect IPv4 via %s on %s: %w", cfg.IPAPI, cfg.Interface, err)
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
zoneID, err := cfGetZoneID(cctx, httpc, cfg.Cloudflare.APIToken, cfg.Cloudflare.ZoneName)
if err != nil {
return err
}
existing, err := cfGetRecord(cctx, httpc, cfg.Cloudflare.APIToken, zoneID, cfg.Cloudflare.RecordName)
if err != nil {
return err
}
if err := cfUpsertA(cctx, httpc, cfg.Cloudflare.APIToken, zoneID, cfg.Cloudflare.RecordName, ip, cfg.Cloudflare.TTL, cfg.Cloudflare.Proxied, existing); err != nil {
return err
}
fmt.Printf("%s set to %s\n", cfg.Cloudflare.RecordName, ip)
return nil
}
func main() {
cfgPath := "/etc/cf-ddns/config.yaml"
if p := os.Getenv("CF_DDNS_CONFIG"); p != "" {
cfgPath = p
}
cfg := must(loadConfig(cfgPath))
dur := must(time.ParseDuration(cfg.Interval))
httpc := httpClientOnInterface(cfg.Interface, 10*time.Second)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// initial run
if err := runOnce(ctx, cfg, httpc); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
}
t := time.NewTicker(dur)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := runOnce(ctx, cfg, httpc); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
}
}
}
}
Key behaviors to note
- The code exits on missing configuration so you discover misconfigurations early.
- The program binds HTTP sockets to a specific interface on Linux by calling SO_BINDTODEVICE via a custom Dialer. This requires root or CAP_NET_RAW.
- Cloudflare writes are idempotent. If the content, TTL and proxied state match, nothing is sent.
- Each run of
runOnce
is wrapped in a fifteen second context timeout so stuck network calls do not block forever. - The main loop listens for SIGINT or SIGTERM and exits cleanly.
Systemd unit
A simple unit file to run the binary as a service and restart it if it crashes. This unit expects the binary at /usr/local/bin/cf-ddns
and the configuration at /etc/cf-ddns/config.yaml
.
[Unit]
Description=Cloudflare DDNS updater (interface-bound)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/cf-ddns
Environment=CF_DDNS_CONFIG=/etc/cf-ddns/config.yaml
# Needs root to use SO_BINDTODEVICE; you can also add AmbientCapabilities if you want to drop full root.
User=root
Group=root
# Hardening (adjust if needed)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_RAW
ReadWritePaths=/etc/cf-ddns
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
Enable and start the service
sudo systemctl enable cf-ddns
sudo systemctl start cf-ddns
sudo systemctl status cf-ddns --no-pager
View logs
journalctl -u cf-ddns -f
Building and packaging releases
The repository should publish prebuilt binaries for common architectures so installation is fast and predictable. The installer supports automatic detection of architecture, but providing matching asset names improves the experience.
Build commands
GOOS=linux GOARCH=amd64 go build -o cf-ddns-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o cf-ddns-arm64 .
Checksums
sha256sum cf-ddns-linux-amd64 > cf-ddns-linux-amd64.sha256
sha256sum cf-ddns-arm64 > cf-ddns-arm64.sha256
Upload the binaries and their checksum files to the release tagged v1.0.0. Expected asset names
- cf-ddns-linux-amd64
- cf-ddns-linux-amd64.sha256
- cf-ddns-arm64
- cf-ddns-arm64.sha256
The installer script
The installer downloads the correct binary for the current machine, verifies the checksum, fetches the raw config and systemd unit from the repository, substitutes a few values in the config file, reloads systemd, and prints the next steps.
#!/usr/bin/env bash
set -euo pipefail
# ================================================================
# cf-ddns installer
# - Downloads the correct binary (auto-arch or explicitly chosen)
# - Verifies with SHA-256 checksum (required by default)
# - Fetches config.yaml and systemd service from your repo
# - Performs inline substitutions (iface/token/zone/record)
# - Reloads systemd and prints next steps
# ================================================================
# -------- Default endpoints (override via env/flags) --------
: "${RELEASE_BASE:=https://github.com/ab-a/cf-ddns/releases/download/v1.0.0}"
: "${RAW_BASE:=https://raw.githubusercontent.com/ab-a/cf-ddns/refs/heads/main}"
: "${CONFIG_URL:=${RAW_BASE}/config.yaml}"
: "${SERVICE_URL:=${RAW_BASE}/cf-ddns.service}"
# -------- Install locations --------
: "${BIN_PATH:=/usr/local/bin/cf-ddns}"
: "${CONF_DIR:=/etc/cf-ddns}"
: "${CONF_FILE:=${CONF_DIR}/config.yaml}"
: "${SERVICE_FILE:=/etc/systemd/system/cf-ddns.service}"
# -------- Optional inline config substitutions --------
: "${IFACE:=}"
: "${CF_API_TOKEN:=}"
: "${CF_ZONE_NAME:=}"
: "${CF_RECORD_NAME:=}"
# -------- Binary selection & verification --------
: "${OS_OVERRIDE:=}"
: "${ARCH_OVERRIDE:=}" # allowed: amd64, arm64
VERIFY=1 # require checksum by default
FROM_SOURCE=0
usage() {
cat <<'EOF'
Install cf-ddns (binary, config, systemd).
Flags / env:
--iface <dev> Network interface (e.g., eth0). Env: IFACE
--token <token> Cloudflare API token. Env: CF_API_TOKEN
--zone <zone> Zone name (e.g., example.com). Env: CF_ZONE_NAME
--record <fqdn> Record name (e.g., host.example.com). Env: CF_RECORD_NAME
--arch <amd64|arm64> Force architecture (auto-detected by default). Env: ARCH_OVERRIDE
--os <linux> Force OS (default linux). Env: OS_OVERRIDE
--no-check Skip checksum verification (NOT recommended).
--from-source Build from source if no asset found.
--release-base <url> Override release base URL
--raw-base <url> Override raw base URL
--config-url <url> Override config URL
--service-url <url> Override service URL
Examples:
sudo ./install.sh --iface wlp4s0 --token sk_live_... --zone example.com --record home.example.com
sudo ./install.sh --arch arm64 --iface eth0 --token ... --zone example.com --record pi.example.com
IFACE=wlp4s0 CF_API_TOKEN=... CF_ZONE_NAME=example.com CF_RECORD_NAME=home.example.com sudo ./install.sh
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--iface) IFACE="${2:-}"; shift 2 ;;
--token) CF_API_TOKEN="${2:-}"; shift 2 ;;
--zone) CF_ZONE_NAME="${2:-}"; shift 2 ;;
--record) CF_RECORD_NAME="${2:-}"; shift 2 ;;
--arch) ARCH_OVERRIDE="${2:-}"; shift 2 ;;
--os) OS_OVERRIDE="${2:-}"; shift 2 ;;
--no-check) VERIFY=0; shift ;;
--from-source) FROM_SOURCE=1; shift ;;
--release-base) RELEASE_BASE="${2:-}"; shift 2 ;;
--raw-base) RAW_BASE="${2:-}"; shift 2 ;;
--config-url) CONFIG_URL="${2:-}"; shift 2 ;;
--service-url) SERVICE_URL="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 2 ;;
esac
done
need_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "This installer needs root. Try: sudo $0 ..."
exit 1
fi
}
detect_iface() {
local guess
if command -v ip >/dev/null 2>&1; then
guess="$(ip route get 1.1.1.1 2>/dev/null | awk '/dev/ {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')"
fi
if [ -z "${guess:-}" ]; then
guess="eth0"
fi
echo "${guess}"
}
normalize_arch() {
local val="${1:-}"
if [ -n "$val" ]; then
echo "$val"; return
fi
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
armv8*|arm64*) echo "arm64" ;;
*) echo "amd64" ;;
esac
}
normalize_os() {
local val="${1:-}"
if [ -n "$val" ]; then
echo "$val"; return
fi
case "$(uname | tr '[:upper:]' '[:lower:]')" in
linux*) echo "linux" ;;
*) echo "linux" ;;
esac
}
download_with_checksum() {
# $1: asset file name to fetch from ${RELEASE_BASE}
local asset="$1"
local asset_url="${RELEASE_BASE}/${asset}"
echo "-> Probing ${asset_url}"
if ! curl -fsI "${asset_url}" >/dev/null 2>&1; then
return 1
fi
echo "-> Downloading ${asset}"
curl -fsSL -o "/tmp/cf-ddns" "${asset_url}"
chmod +x "/tmp/cf-ddns"
if [ "${VERIFY}" -eq 1 ]; then
# Prefer "${asset}.sha256"; fall back to "checksums.txt" style if you add one later
local sha_url="${asset_url}.sha256"
if curl -fsSL -o "/tmp/${asset}.sha256" "${sha_url}" ; then
echo "-> Verifying checksum for ${asset}"
(cd /tmp && sha256sum -c "${asset}.sha256")
else
# Try a generic checksum file name
if curl -fsSL -o "/tmp/checksums.txt" "${RELEASE_BASE}/checksums.txt" ; then
echo "-> Verifying checksum via checksums.txt"
(cd /tmp && grep " ${asset}$" checksums.txt | sha256sum -c -)
else
echo "!! Checksum file not found for ${asset}."
echo " Provide ${asset}.sha256 or checksums.txt in your release, or re-run with --no-check"
exit 1
fi
fi
else
echo "-> Skipping checksum verification (--no-check)"
fi
echo "-> Installing to ${BIN_PATH}"
install -m 0755 "/tmp/cf-ddns" "${BIN_PATH}"
return 0
}
download_binary() {
local os arch
os="$(normalize_os "${OS_OVERRIDE}")"
arch="$(normalize_arch "${ARCH_OVERRIDE}")"
echo "-> Target: OS=${os}, ARCH=${arch}"
# Try arch-specific names first, then plain
local candidates=(
"cf-ddns-${os}-${arch}"
"cf-ddns-${arch}"
"cf-ddns"
)
for cand in "${candidates[@]}"; do
if download_with_checksum "${cand}"; then
echo "-> Installed binary: ${cand}"
return 0
fi
done
echo "!! No matching release asset found at ${RELEASE_BASE}"
if [ "${FROM_SOURCE}" -eq 1 ]; then
build_from_source
return 0
fi
cat <<EOF
Hint: Upload one of these asset names for this target:
- cf-ddns-${os}-${arch}
- cf-ddns-${arch}
- cf-ddns
Or re-run with: --from-source (to build locally)
EOF
exit 1
}
build_from_source() {
echo "-> Building from source (FROM_SOURCE=1 or no assets)"
command -v git >/dev/null 2>&1 || { echo "git required to build from source"; exit 1; }
command -v go >/dev/null 2>&1 || { echo "go toolchain required to build from source"; exit 1; }
tmpdir="$(mktemp -d)"
trap 'rm -rf "${tmpdir}"' EXIT
echo "-> Cloning repository"
git clone --depth=1 https://github.com/ab-a/cf-ddns "${tmpdir}/cf-ddns"
( cd "${tmpdir}/cf-ddns" && GOOS="$(normalize_os "${OS_OVERRIDE}")" GOARCH="$(normalize_arch "${ARCH_OVERRIDE}")" go build -o /tmp/cf-ddns . )
install -m 0755 "/tmp/cf-ddns" "${BIN_PATH}"
echo "-> Installed from source to ${BIN_PATH}"
}
download_config() {
echo "-> Fetching config from ${CONFIG_URL}"
install -d -m 0755 "${CONF_DIR}"
curl -fsSL -o "${CONF_FILE}" "${CONFIG_URL}"
chmod 0640 "${CONF_FILE}" || true
local iface_val token_val zone_val record_val
iface_val="${IFACE:-$(detect_iface)}"
token_val="${CF_API_TOKEN:-}"
zone_val="${CF_ZONE_NAME:-}"
record_val="${CF_RECORD_NAME:-}"
echo "-> Updating config values:"
echo " interface=${iface_val}"
[ -n "${token_val}" ] && echo " api_token=<provided>"
[ -n "${zone_val}" ] && echo " zone_name=${zone_val}"
[ -n "${record_val}" ] && echo " record_name=${record_val}"
# Update 'interface:' at top-level
sed -i -E "s|^([[:space:]]*interface:[[:space:]]*).*$|\1"${iface_val}"|g" "${CONF_FILE}"
# Update within cloudflare: block
if [ -n "${token_val}" ]; then
sed -i -E "/^cloudflare:/,/^[^[:space:]]/ s|^([[:space:]]*api_token:[[:space:]]*).*$|\1"${token_val}"|g" "${CONF_FILE}"
fi
if [ -n "${zone_val}" ]; then
sed -i -E "/^cloudflare:/,/^[^[:space:]]/ s|^([[:space:]]*zone_name:[[:space:]]*).*$|\1"${zone_val}"|g" "${CONF_FILE}"
fi
if [ -n "${record_val}" ]; then
sed -i -E "/^cloudflare:/,/^[^[:space:]]/ s|^([[:space:]]*record_name:[[:space:]]*).*$|\1"${record_val}"|g" "${CONF_FILE}"
fi
}
download_service() {
echo "-> Fetching systemd service from ${SERVICE_URL}"
curl -fsSL -o "${SERVICE_FILE}" "${SERVICE_URL}"
chmod 0644 "${SERVICE_FILE}"
echo "-> Reloading systemd daemon"
systemctl daemon-reload || true
}
print_next_steps() {
cat <<EOF
======================================================================
Installation complete.
Edit your config:
${CONF_FILE}
Verify or change
interface detected: ${IFACE:-$(detect_iface)}
cloudflare.api_token
cloudflare.zone_name
cloudflare.record_name
Quick test
CF_DDNS_CONFIG=${CONF_FILE} ${BIN_PATH}
Enable and start
sudo systemctl enable cf-ddns
sudo systemctl start cf-ddns
sudo systemctl status cf-ddns --no-pager
Logs
journalctl -u cf-ddns -f
Re-run with explicit arch
sudo ./install.sh --arch amd64 --iface eth1 --token TOKEN --zone example.com --record ddns.example.com
sudo ./install.sh --arch arm64 --iface eth1 --token TOKEN --zone example.com --record ddns.example.com
Curl and bash once this script is hosted
curl -fsSL https://raw.githubusercontent.com/ab-a/cf-ddns/main/install.sh | sudo bash -s -- \
--arch amd64 --iface wlp4s0 --token TOKEN --zone example.com --record ddns.example.com
======================================================================
EOF
}
main() {
need_root
command -v curl >/dev/null 2>&1 || { echo "curl is required"; exit 1; }
download_binary
download_config
download_service
print_next_steps
}
main "$@"
Usage examples
sudo bash install.sh --iface eth1 --token CLOUDFLARE_TOKEN --zone example.com --record home.example.com
sudo bash install.sh --arch arm64 --iface eth1 --token CLOUDFLARE_TOKEN --zone example.com --record ddns.example.com
Release notes template
Tag v1.0.0 and publish release notes that include build instructions and checksum guidance.
v1.0.0 – Initial Cloudflare Dynamic DNS Updater
Features
Detects public IPv4 and upserts a Cloudflare A record
Periodic updates via a configurable interval
Optional interface binding via SO_BINDTODEVICE on Linux
YAML configuration and systemd unit
Installation and build
go get gopkg.in/yaml.v3
go build -o cf-ddns-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o cf-ddns-arm64 .
Checksums
sha256sum cf-ddns-linux-amd64 > cf-ddns-linux-amd64.sha256
sha256sum cf-ddns-arm64 > cf-ddns-arm64.sha256
Troubleshooting
Checksum fails during install
- Make sure each binary has a matching
.sha256
file beside it in the release. - The
.sha256
file must contain the output ofsha256sum <file>
on a single line.
Installer cannot find a release asset
- Verify the asset names match the expected patterns for your architecture.
- For Linux on x86_64 use
cf-ddns-linux-amd64
. - For Linux on arm64 use
cf-ddns-arm64
. - If needed, rerun the installer with
--from-source
to compile locally.
Permission errors binding to an interface
- Run as root or give the binary CAP_NET_RAW if you keep SO_BINDTODEVICE in place.
- Alternatively remove the binding code path if you do not need it.
Configuration fields are ignored
- Confirm the correct config path is in use. Either set
CF_DDNS_CONFIG
or place your file at/etc/cf-ddns/config.yaml
. - Check for stray indentation in the YAML file.
Closing notes
This is a practical pattern for a small daemon. It is a single Go file with a single external dependency for YAML. It does one job and stays quiet unless there is an error or an IP change. With prebuilt binaries, checksums and a simple installer, you can deploy it repeatably anywhere Linux runs.