Is there a trick to getting systemd user timers to work?

I’ve got a bash script I want to have run every day, to perform backups. I’ve created a timer and service for them, in Home Manager:

  systemd.user.services = {
    kopia = {
      Unit = {
        Description = "Kopia backup";
        After = [ "network.target" ];
      };
      Service = {
        Type = "oneshot";
        ExecStart = "/home/nairou/scripts/kopia.sh";
      };
      Install.WantedBy = [ "default.target" ];
    };
  };

  systemd.user.timers = {
    kopia = {
      Unit.Description = "Kopia backup schedule";
      Timer = {
        Unit = "kopia";
        OnCalendar = "06:00";
      };
      Install.WantedBy = [ "timers.target" ];
    };
  };

I’m new to systemd timers and services, but as far as I can tell, this is correct. However, when applied and the timer and service are created, the timer looks okay but the service shows an error.

❯ systemctl --user status kopia.timer
● kopia.timer - Kopia backup schedule
     Loaded: loaded (/home/nairou/.config/systemd/user/kopia.timer; enabled; preset: enabled)
     Active: active (waiting) since Mon 2023-05-08 15:12:39 EDT; 1 day 2h ago
    Trigger: Wed 2023-05-10 06:00:00 EDT; 11h left
   Triggers: ● kopia.service

❯ systemctl --user status kopia.service
× kopia.service - Kopia backup
     Loaded: loaded (/home/nairou/.config/systemd/user/kopia.service; enabled; preset: enabled)
     Active: failed (Result: exit-code) since Tue 2023-05-09 17:43:56 EDT; 24min ago
   Duration: 3ms
TriggeredBy: ● kopia.timer
    Process: 259426 ExecStart=/home/nairou/scripts/kopia.sh (code=exited, status=127)
   Main PID: 259426 (code=exited, status=127)
        CPU: 2ms

I don’t understand why the service would give an error. I did a search for error 127, it says “command not found”, but the script runs fine manually.

What else could be the cause? Do I have an error in my service config?

2 Likes

What’s inside the kopia.sh script?

Systemd units are launched with an almost empty environment, including PATH, so they don’t have all the packages from your system and home environments on their PATH.

I am guessing that you run some commands in the script that cannot be found. So you need to either explicitly add those packages to the path for your service (setting PATH in the unit’s environment), or template the script using nix so that you can have nix insert the full store paths to all the commands.

1 Like

The script just has a few calls to kopia, which was installed via Home Manager. I tried prefixing it with /usr/bin/env to look up the path where it is installed, but that didn’t help.

However, since you mentioned it, I tried another test where the script was nothing but

#!/usr/bin/env bash

echo test!

And that also failed. But changing the first like to just use /bin/sh suddenly made it succeed.

Why would a script run by systemd not have access to bash? And why isn’t /usr/bin/env finding it?

I’m new to NixOS, so I don’t actually know how what path to give for installed programs…

The packages that you install with home-manager are present on the PATH that’s set by your shell’s startup config, but they are not by default added to the PATH for all systemd units. This is a feature actually, because you can use totally different versions of packages in systemd units than the ones that you have in your shell.

/usr/bin/env can only find things that are included in PATH, and so it won’t find kopia or bash if they are not added to PATH.

The easiest way to fix this, is to template the script directly from nix:

{ lib, pkgs, ... }:

{
  systemd.user.services.kopia = {
    <...>
    Service = {
      <...>
      ExecStart = toString (
        pkgs.writeShellScript "kopia-start-script.sh" ''
          set -eou pipefail

          ${pkgs.kopia}/bin/kopia <...>
        ''
      );
    };      
  };
}

The expression ${pkgs.kopia} will evaluate to the store path where kopia can be found, so it will be an absolute path and you don’t need to bother with PATH.
The writeShellScript function will automatically include a she-bang with the absolute path to bash, so you don’t need to worry about that either.

3 Likes

Oh wow, I like this solution. Nix keeps surprising me. Thank you!

Because my script contains multiple packages like acpi, awk, tr so how would i go with ${pkgs.kopia} expression. Here is my battery_status.sh:

 #!/run/current-system/sw/bin/bash
   2   │
   3   │ # define global variables
   4   │ # BATTINFO store full battery info
   5   │ BATTINFO=$(acpi -b | grep Battery\ 0)
   6   │
   7   │ # to store percentage
   8   │ percentage=$(echo "$BATTINFO" | awk '{print $4}' | tr -d '%,')
   9   │
  10   │ # for Command Substitution backticks(`) are depricated instead use this syntax $()
  11   │ # if battery is discharging and is between 48% and 30%
  12   │ if [[ $(echo $BATTINFO | grep Discharging) && "$percentage" -lt 45 && "$percentage" -gt 15 ]] ; then
  13   │
  14   │     # to refrence a variable use this syntax $varibale_name
  15   │     dunstify "low battery" "$BATTINFO"
  16   │
  17   │ # if battery is discharging and is less than 30%
  18   │ elif [[ $(echo $BATTINFO | grep Discharging) && "$percentage" -lt 15 ]]; then
  19   │
  20   │     # set the emergency level to critical
  21   │     dunstify -u critical "low battery" "$BATTINFO"
  22   │ fi

And in Home-Manager:

# This timer runs every 5 minutes to run bash script battery_status.sh
 101   │   systemd.user.services = {
 102   │     battery_status = {
 103   │       Unit = {
 104   │         Description = "low battery notification service";
 105   │       };
 106   │       Service = {
 107   │         Type = "oneshot";
 108   │         ExecStart = toString (
 109   │          pkgs.writeShellScript "battery-status-script" ''
 110   │           set -eou pipefail
 111   │
 112   │           ${pkgs.bash}/bin/bash "/home/sukhman/Documents/sway_related/battery_status.sh";
 113   │      ''
 114   │     );
 115   │       };
 116   │       Install.WantedBy = [ "default.target" ];
 117   │     };
 118   │   };
 119   │
 120   │   systemd.user.timers = {
 121   │     battery_status = {
 122   │       Unit.Description = "timer for battery_status service";
 123   │       Timer = {
 124   │         Unit = "battery_status";
 125   │     OnBootSec = "1m";
 126   │         OnUnitActiveSec = "1m";
 127   │       };
 128   │       Install.WantedBy = [ "timers.target" ];
 129   │     };
 130   │   };

Error: i am getting command not found for acpi, awk, and tr

maybe

        Environment = "PATH=/run/current-system/sw/bin";

as a shortcut.

Also I had to enable the timer manually.

I got mine to work like this; running bash sets the correct path (in this case, for python):

  systemd.user.services.vm_info = {
    description = "Updates virtual machine config data";
    enable = true;
    unitConfig = {
      RequiresMountsFor = "/media/host/Shared-Files";
    };
    serviceConfig = {
      Type = "simple";
      ExecStart = "/run/current-system/sw/bin/bash -l -c 'python3 /media/host/Shared-Files/vm_info/get_vm_info_static.py'";
    };
    wantedBy = [ "default.target" ];
  };