13 min read

Building a Small Cloudflare Dynamic DNS Updater in Go

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

  1. 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.
  2. Look up the Cloudflare zone id for the configured zone.
  3. Query the DNS record by name.
  4. Create or update the A record only if TTL, proxied flag or content changed.
  5. Repeat on a configurable interval.
  6. Bind outbound HTTP sockets to a specific interface using SO_BINDTODEVICE on Linux when desired.
  7. 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

  1. The code exits on missing configuration so you discover misconfigurations early.
  2. 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.
  3. Cloudflare writes are idempotent. If the content, TTL and proxied state match, nothing is sent.
  4. Each run of runOnce is wrapped in a fifteen second context timeout so stuck network calls do not block forever.
  5. 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

  1. cf-ddns-linux-amd64
  2. cf-ddns-linux-amd64.sha256
  3. cf-ddns-arm64
  4. 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

  1. Make sure each binary has a matching .sha256 file beside it in the release.
  2. The .sha256 file must contain the output of sha256sum <file> on a single line.

Installer cannot find a release asset

  1. Verify the asset names match the expected patterns for your architecture.
  2. For Linux on x86_64 use cf-ddns-linux-amd64.
  3. For Linux on arm64 use cf-ddns-arm64.
  4. If needed, rerun the installer with --from-source to compile locally.

Permission errors binding to an interface

  1. Run as root or give the binary CAP_NET_RAW if you keep SO_BINDTODEVICE in place.
  2. Alternatively remove the binding code path if you do not need it.

Configuration fields are ignored

  1. Confirm the correct config path is in use. Either set CF_DDNS_CONFIG or place your file at /etc/cf-ddns/config.yaml.
  2. 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.