Change:
<ins> { config, lib, pkgs, ... }: with lib; let cfg = config.services.inadyn; # check if a value of an attrset is not null or an empty collection nonEmptyValue = _: v: v != null && v != [ ] && v != { }; renderOption = k: v: if builtins.elem k [ "provider" "custom" ] then lib.concatStringsSep "n" (mapAttrsToList (name: config: '' ${k} ${name} { ${lib.concatStringsSep "n    " (mapAttrsToList renderOption (filterAttrs nonEmptyValue config))} }'') v) else if k == "include" then "${k}("${v}")" else if k == "hostname" && builtins.isList v then "${k} = { ${builtins.concatStringsSep ", " (map (s: ""${s}"") v)} }" else if builtins.isBool v then "${k} = ${boolToString v}" else if builtins.isString v then "${k} = "${v}"" else "${k} = ${toString v}"; configFile' = pkgs.writeText "inadyn.conf" '' # This file was generated by nix # do not edit ${(lib.concatStringsSep "n" (mapAttrsToList renderOption (filterAttrs nonEmptyValue cfg.settings)))} ''; configFile = if (cfg.configFile != null) then cfg.configFile else configFile'; in { options.services.inadyn = with types; let providerOptions = { include = mkOption { default = null; description = mdDoc "File to include additional settings for this provider from."; type = nullOr path; }; ssl = mkOption { default = true; description = mdDoc "Whether to use HTTPS for this DDNS provider."; type = bool; }; username = mkOption { default = null; description = mdDoc "Username for this DDNS provider."; type = nullOr str; }; password = mkOption { default = null; description = mdDoc '' Password for this DDNS provider. WARNING: This will be world-readable in the nix store. To store credentials securely, use the `include` or `configFile` options. ''; type = nullOr str; }; hostname = mkOption { default = "*"; example = "your.cool-domain.com"; description = mdDoc "Hostname alias(es)."; type = either str (listOf str); }; }; in { enable = mkEnableOption (mdDoc '' synchronise your machine's IP address with a dynamic DNS provider using inadyn ''); user = mkOption { default = "inadyn"; type = types.str; description = lib.mdDoc '' User account under which inadyn runs. ::: {.note} If left as the default value this user will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the inadyn service starts. ::: ''; }; group = mkOption { default = "inadyn"; type = types.str; description = lib.mdDoc '' Group account under which inadyn runs. ::: {.note} If left as the default value this user will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the inadyn service starts. ::: ''; }; interval = mkOption { default = "*-*-* *:*:00"; description = mdDoc '' How often to check the current IP. Uses the format described in {manpage}`systemd.time(7)`"; ''; type = str; }; logLevel = lib.mkOption { type = lib.types.enum [ "none" "err" "warning" "info" "notice" "debug" ]; default = "notice"; description = lib.mdDoc "Set inadyn's log level."; }; settings = mkOption { default = { }; description = "See `inadyn.conf (5)`"; type = submodule { freeformType = attrs; options = { allow-ipv6 = mkOption { default = config.networking.enableIPv6; defaultText = "`config.networking.enableIPv6`"; description = mdDoc "Whether to get IPv6 addresses from interfaces."; type = bool; }; forced-update = mkOption { default = 2592000; description = mdDoc "Duration (in seconds) after which an update is forced."; type = ints.positive; }; provider = mkOption { default = { }; description = mdDoc '' Settings for DDNS providers built-in to inadyn. For a list of built-in providers, see `inadyn.conf (5)`. ''; type = attrsOf (submodule { freeformType = attrs; options = providerOptions; }); }; custom = mkOption { default = { }; description = mdDoc '' Settings for custom DNS providers. ''; type = attrsOf (submodule { freeformType = attrs; options = providerOptions // { ddns-server = mkOption { description = mdDoc "DDNS server name."; type = str; }; ddns-path = mkOption { description = mdDoc '' DDNS server path. See `inadnyn.conf (5)` for a list for format specifiers that can be used. ''; example = "/update?user=%u&password=%p&domain=%h&myip=%i"; type = str; }; }; }); }; }; }; }; configFile = mkOption { default = null; description = mdDoc '' Configuration file for inadyn. Setting this will override all other configuration options. Passed to the inadyn service using LoadCredential. ''; type = nullOr path; }; }; config = lib.mkIf cfg.enable { systemd = { services.inadyn = { description = "Update nameservers using inadyn"; documentation = [ "man:inadyn" "man:inadyn.conf" "file:${pkgs.inadyn}/share/doc/inadyn/README.md" ]; requires = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; startAt = cfg.interval; serviceConfig = { Type = "oneshot"; ExecStart = ''${lib.getExe pkgs.inadyn} -f ${configFile} --cache-dir ''${CACHE_DIRECTORY}/inadyn -1 --foreground -l ${cfg.logLevel}''; LoadCredential = "config:${configFile}"; CacheDirectory = "inadyn"; User = cfg.user; Group = cfg.group; UMask = "0177"; LockPersonality = true; MemoryDenyWriteExecute = true; RestrictAddressFamilies = "AF_INET AF_INET6 AF_NETLINK"; NoNewPrivileges = true; PrivateDevices = true; PrivateTmp = true; PrivateUsers = true; ProtectSystem = "strict"; ProtectProc = "invisible"; ProtectHome = true; ProtectClock = true; ProtectControlGroups = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; SystemCallErrorNumber = "EPERM"; SystemCallFilter = "@system-service"; CapabilityBoundingSet = ""; }; }; timers.inadyn.timerConfig.Persistent = true; }; users.users.inadyn = mkIf (cfg.user == "inadyn") { group = cfg.group; isSystemUser = true; }; users.groups = mkIf (cfg.group == "inadyn") { inadyn = { }; }; }; } </ins>

Discussion:
ddclient was removed in , with inadyn being the closest suggested replacement. inadyn is packaged in Nixpkgs, but does not have a NixOS module. An inadyn NixOS module would be useful as a replacement.
The first step is fixing inadyn itself, since it currently won't start due to a wrongly configured , see .
We will also need to decide if it's better to run as a systemd service, timer, or give the user the option to choose. I lean towards timer.
AFAICS inadyn does not contain additional logic to detect IP address changes. Because of this I would also lean towards the timer functionality.
I think it does. Looking at , inadyn will only update the ip if changed every seconds and always update it every seconds. I'm not clear on how those options behave in oneshot mode.
Well, so far I called ddclient with cron, since the machine running will not easil see altered public IP change. I tried to use inadyn but can't get it to work with /
I've had some time to poke around this a bit more, and it seems like inadyn cannot read credentials from the environment or a file separate to the config, so all your DDNS credentials will end up world-readable in the store.
I've made a pretty barebones module for my personal config here It runs inadyn in oneshot mode using a timer, and is configured entirely by specufying a .
Submitted a full NixOS module as , I would appreciate if inadyn users could test it :slightlysmilingface:.
Add a small example for how to use it?
Sure, here's a config to use namecheap: