Skip to content

How to Run Cron Jobs and Scheduled Tasks on a UGREEN NAS (UGOS)

UGOS has no task scheduler for your own scripts. I wanted my UGREEN NAS to run a nightly backup and a few scripts on a timer, and the usual first move, crontab -e over SSH, fails for a normal user. The options that do work all share one trap: UGOS mounts your storage several seconds after the schedulers start, so a job that runs at boot can find /home and /volume1 missing. That trap silently killed every scheduled job on my NAS six times in a month before I worked out the fix.

This post covers why crontab fails, a reboot test of four ways to schedule jobs, and step-by-step setups for the three worth using. All of them need SSH: turn it on in Control Panel > Terminal, and log in with an administrator account so sudo works.

Tested 4 November 2026 on a UGREEN DXP480T Plus running UGOS Pro 1.20.0.0142 (Debian 12 underneath, systemd 252), with a reboot to measure when each scheduler first runs. The NAS runs 11 scheduled jobs this way every day.

Short answer

  • Use systemd user timers for most jobs. They need two one-time setup steps with sudo, after which you manage everything as your own user. Without the second one (a small boot fix), they die on every reboot.
  • Use a file in /etc/cron.d if you prefer cron syntax. Root writes the file once, the job runs as your user.
  • Use a cron container if you don’t want to touch the system at all. It starts after storage is ready, but the jobs only see what you mount into the container.
  • Don’t fight crontab -e. UGOS ships it broken for normal users, on purpose or not.

Why crontab -e fails on UGOS

The cron daemon is installed and running, but a normal user can’t save a crontab:

$ crontab -l
no crontab for marius
$ echo '0 3 * * * /home/marius/bin/nightly.sh' | crontab -
/var/spool/cron/: mkstemp: Permission deniedCode language: plaintext (plaintext)

On stock Debian, crontab is a setgid program in the crontab group, and that group may write to the spool directory. UGOS ships the same cron package (3.0pl1-162) with both permissions stripped:

# UGOS Pro 1.20
-rwxr-xr-x root:root     /usr/bin/crontab
drwxr-xr-x root:root     /var/spool/cron/crontabs

# Debian 12, same cron package
-rwxr-sr-x root:crontab  /usr/bin/crontab
drwx-wx--T root:crontab  /var/spool/cron/crontabsCode language: plaintext (plaintext)

You could put the permissions back with sudo, but the UGOS system files sit on firmware images, and I’d rather not change files that a firmware update owns. Using root’s crontab instead would run every job as root. The options below avoid both.

The boot trap: storage arrives last

UGOS mounts its storage pool itself, outside systemd. On my test boot that happened between 8 and 13 seconds after boot. By then cron and systemd’s timers are already running. To measure it, I set up one job per method that writes the uptime and whether /home and /volume1 exist, then rebooted:

MethodFirst run after boot/home/volume1
System timer (OnBootSec=1s)6.0 smissingmissing
/etc/cron.d @reboot7.9 smissingmissing
User timer (with the fix below)15.7 spresentpresent
Docker container start21.7 sn/apresent
/etc/cron.d @reboot with sleep 6067.9 spresentpresent

My test script lived in /usr/local/bin, so it could run that early. A script stored in your home folder or on /volume1 doesn’t exist yet at 6 to 8 seconds, so a boot-time run fails with “not found”. Regular schedules such as “every night at 03:30” are not affected, because the storage is there by then. The trap only bites jobs that run at boot: @reboot lines, OnBootSec=, and missed runs that a timer catches up after the NAS was off.

The trap is worse for systemd user timers, the method I recommend. The user’s systemd instance starts before /home is mounted, finds no unit files in ~/.config/systemd/user, and loads no timers at all. They show as enabled but never fire, with no error anywhere. That happened on six reboots of my NAS between 17 September and 12 October 2026, until I added the fix in step 4.

A timer is two small files: a .service that says what to run and a .timer that says when. Everything lives in your home folder, and you manage it without sudo after the setup.

1. Let your jobs run while you are logged out

By default, systemd stops your user instance when your last SSH session ends, and your timers stop with it. “Linger” keeps it running from boot:

sudo loginctl enable-linger "$USER"
loginctl show-user "$USER" -p Linger   # should print Linger=yesCode language: Bash (bash)

2. Write the service and the timer

Put your script at ~/bin/nightly.sh, start it with #!/bin/sh, and make it executable with chmod +x ~/bin/nightly.sh. This example runs ~/bin/nightly.sh every night at 03:30 and appends its output to ~/logs/nightly.log:

mkdir -p ~/.config/systemd/user ~/logs

cat > ~/.config/systemd/user/nightly.service <<'EOF'
[Unit]
Description=Nightly job

[Service]
Type=oneshot
ExecStart=%h/bin/nightly.sh
StandardOutput=append:%h/logs/nightly.log
StandardError=inherit
EOF

cat > ~/.config/systemd/user/nightly.timer <<'EOF'
[Unit]
Description=Run the nightly job at 03:30

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target
EOFCode language: Bash (bash)

%h is your home folder. Persistent=true runs a missed job once the NAS is back on, if it was off at 03:30. Write the output to a file as shown: on my NAS, journalctl --user only reached back about an hour, so it can’t tell you what last night’s run printed.

3. Enable it

systemctl --user daemon-reload
systemctl --user enable --now nightly.timer
systemctl --user list-timersCode language: Bash (bash)

list-timers shows the next and last run of every timer. To check a schedule before you use it, run systemd-analyze calendar "*-*-* 03:30:00", which prints the next time it will fire. systemctl --user status nightly.service shows whether the last run failed.

One surprise with Persistent=true: if you add a time slot that has already passed today to a timer that has run before, then reload and restart it, it runs straight away, because systemd counts today’s slot as missed.

4. Make the timers survive a reboot

This is the fix for the boot trap. The textbook answer is RequiresMountsFor=/home, and it does nothing on UGOS. The home.mount unit has no unit file: UGOS mounts /home itself, systemd only notices afterwards, and nothing ever waits for it. What works is a small loop that holds back your user instance until your unit folder exists, for at most 60 seconds. Before you paste it, replace 1000 with your user id and marius with your user name in all four places (two paths with 1000, two with marius). id -u and id -un print them. With the wrong name, the loop waits for a folder that never appears, and the fix silently does nothing.

sudo mkdir -p /etc/systemd/system/[email protected]
sudo tee /etc/systemd/system/[email protected]/home-mount.conf <<'EOF'
[Unit]
RequiresMountsFor=/home/marius

[Service]
# UGOS mounts /home outside systemd, so RequiresMountsFor alone never waits.
# Block until the user unit folder is visible (max 60 s).
ExecStartPre=/bin/sh -c 'i=0; while [ ! -d /home/marius/.config/systemd/user ] && [ $$i -lt 60 ]; do sleep 1; i=$$((i+1)); done'
TimeoutStartSec=120
EOF
sudo systemctl daemon-reloadCode language: Bash (bash)

The doubled $$ is deliberate: systemd turns it into a single $ before the shell sees it. The RequiresMountsFor line does nothing today; it only helps if a future UGOS makes /home a real systemd mount.

On the test boot, systemd tried to start my user instance at 20:57:00, the loop held it until 20:57:05, and then all 11 timers loaded with the storage in place. It also held through the reboots on 2 and 3 November. Your changes to system folders such as /etc and /var/lib live in a writable layer on top of the firmware. The linger setting from step 1, which I made in July 2026, survived a UGOS firmware update in September 2026. The drop-in and all 11 timers survived the update to UGOS 1.21.0.0190 on 4 November, where the loop again waited 5 seconds. Check after each update anyway:

systemctl show -p DropInPaths user@$(id -u).service   # should include .../home-mount.conf
systemctl --user list-timers                     # after a reboot: your timers, not "0 timers listed"Code language: Bash (bash)

Option 2: a file in /etc/cron.d

Debian’s cron reads every file in /etc/cron.d, and each line names the user the job runs as. Only creating the file needs sudo:

mkdir -p ~/logs
sudo tee /etc/cron.d/nightly <<'EOF'
# m h dom mon dow user command
30 3 * * * marius /home/marius/bin/nightly.sh >> /home/marius/logs/nightly.log 2>&1
@reboot marius sleep 60; /home/marius/bin/boot-task.sh >> /home/marius/logs/boot.log 2>&1
EOFCode language: Bash (bash)

Things that will trip you up:

  • Cron skips a file whose name contains a dot (nightly.cron is ignored), and the file must be owned by root and not writable by anyone else. sudo tee gets both right.
  • Keep the sleep 60 on @reboot lines. Without it, the job runs about 8 seconds after boot, before /home and /volume1 exist.
  • Redirect the output. Cron normally mails it, but UGOS has no mail program, so anything you don’t redirect is thrown away.
  • Escape % as \%: cron turns a bare % into a newline, so date +%F breaks the line. (The container in option 3 uses BusyBox cron, which doesn’t.)
  • Cron’s PATH is just /usr/bin:/bin. Use full paths in your script, or set PATH= at the top of the file.

Option 3: a cron container

If you’d rather not touch the system, run cron in a container. The UGOS Docker app starts containers after the storage is ready (21.7 seconds after boot in my test), and restart: unless-stopped brings the container back after every reboot. Put these two files in a folder such as /volume1/docker/cronjobs:

services:
  cron:
    image: alpine:3.20
    container_name: cronjobs
    # BusyBox crond ignores crontab files not owned by root, so install a copy at start
    command: sh -c "crontab /config/crontab && exec crond -f -l 8"
    restart: unless-stopped
    volumes:
      - ./crontab:/config/crontab:ro
      - /etc/localtime:/etc/localtime:ro
      - ./logs:/logsCode language: YAML (yaml)
30 3 * * * echo "$(date '+%F %T') nightly ran" >> /logs/nightly.log 2>&1Code language: plaintext (plaintext)

Start it with docker compose up -d in that folder, or create it as a project in the Docker app. Two gotchas cost me a test run each:

  • The obvious mount doesn’t work. Mounting the file straight to /etc/crontabs/root runs nothing, with no error. Files on /volume1 belong to your user, and BusyBox crond only loads crontabs owned by root. The crontab /config/crontab step in the command installs a root-owned copy. Restart the container after editing the file.
  • The container runs on UTC. Without the /etc/localtime mount, 30 3 * * * fires at 03:30 UTC, which is 05:30 in Bucharest in winter and 06:30 in summer. Setting TZ=Europe/Bucharest instead doesn’t help: the Alpine image has no time zone data, so it silently stays on UTC.

The jobs run inside the container, so they only see the folders you mount and the tools the image has. That suits simple file jobs. For anything that needs the NAS’s own tools (rsync with the host’s paths, docker itself), use option 1 or 2.

Which one to pick

User timers/etc/cron.dCron container
sudo neededTwice, at setupTo create or edit the fileNo (Docker group)
Runs asYour userThe user on each lineRoot inside the container
Safe at bootYes, with the fix in step 4Only with a sleepYes
Missed runs caught upYes (Persistent=true)NoNo
Sees the whole NASYesYesOnly mounted folders

I use user timers for all 11 jobs on my NAS, because a missed 03:30 backup runs as soon as the NAS is back on. Whichever you pick, add an alert for when a job stops running: a silent failure is the default on UGOS. My most important jobs ping an Uptime Kuma push monitor at the end of each run, and Kuma alerts me when a ping is overdue. Those jobs drive the AI agent from my post on running Hermes Agent on a NAS.

Leave a Reply

Your email address will not be published. Required fields are marked *