Systemd Timers in WSL: Scheduling Work Without Assuming Uptime
Build reliable systemd timer jobs in WSL, understand Persistent catch-up, and use Windows scheduling when a stopped distro must be started.
Systemd timers are useful in WSL for recurring maintenance, report generation, local cache refreshes, and other jobs that should run while a distribution is active. The dangerous assumption is that enabling a timer makes WSL a server that wakes itself at a wall-clock deadline. It does not. A timer is evaluated by a running systemd manager inside a running distribution; WSL owns the lifetime of that distribution and its lightweight VM.
The reliable design starts by deciding whether the job is allowed to wait until the next WSL launch or whether Windows must explicitly start the distribution. The first case fits an ordinary OnCalendar= timer with optional catch-up. The second needs a Windows-side scheduler or another external orchestrator. Persistent=true helps with missed calendar events after the timer manager returns. It is not an alarm clock for a stopped VM.
A timer activates a service unit
A .timer unit is not a script runner by itself. It describes when systemd should activate a separate unit, commonly a matching .service unit. Calendar expressions use real time; monotonic expressions such as OnBootSec= or OnUnitActiveSec= are relative to a manager or unit lifecycle. A timer may combine multiple expressions, but each has distinct semantics. Calendar timers are also subject to time synchronization and AccuracySec= rather than promising execution at an exact second.
For a system-level job, create a service with an explicit working directory, executable, and output policy. For example, install a small, idempotent script at /usr/local/sbin/refresh-local-index and create /etc/systemd/system/refresh-local-index.service:
[Unit]
Description=Refresh a local development index
[Service]
Type=oneshot
User=alice
Group=alice
WorkingDirectory=/home/alice/project
ExecStart=/usr/local/sbin/refresh-local-index
The script should handle retries and partial work deliberately. A timer does not make a non-idempotent command safe to repeat. If it reads network services or source files, define what happens when those inputs are unavailable, and direct progress or failure output to journald or a documented file rather than depending on an interactive shell.
Create /etc/systemd/system/refresh-local-index.timer:
[Unit]
Description=Run the local index refresh every day
[Timer]
OnCalendar=*-*-* 07:30:00
Persistent=true
AccuracySec=1min
Unit=refresh-local-index.service
[Install]
WantedBy=timers.target
Enable the timer, not the oneshot service:
sudo systemctl daemon-reload
sudo systemctl enable --now refresh-local-index.timer
systemctl list-timers --all
systemctl status refresh-local-index.timer
The service can also be started manually for a deterministic test with sudo systemctl start refresh-local-index.service. Check the result with systemctl status and journalctl -u refresh-local-index.service. systemctl list-timers --all shows the next and most recent activation; it is a better verification surface than assuming an enabled symlink means the task actually ran.
What Persistent means, precisely
Systemd’s Persistent= setting applies to timers that use OnCalendar=. When true, systemd stores when the service last triggered. If at least one calendar event would have occurred while the timer was inactive, the service is started when the timer becomes active again, subject to configured delays such as RandomizedDelaySec=. This is a catch-up mechanism for a timer manager that later starts. It does not queue one execution for every missed interval, and it does not automatically start a stopped machine or WSL distribution.
That distinction matters in WSL. Suppose the distro is running and the 07:30 event is scheduled. If WSL stops before the deadline, no Linux timer manager is present at 07:30 to observe the event. When the distro next starts, systemd can evaluate the persistent calendar timer and run a catch-up if the timer was enabled and its persistent state says an event was missed. A 15-minute job that stopped halfway through is not automatically resumed from its last instruction; service behavior still determines whether repeating it is safe.
OnBootSec=5min is not a Windows boot scheduler. Its reference point is the start of the systemd manager in the distro. If a WSL distro is first launched hours after Windows boot, the timer’s notion of boot is that distro startup, not the earlier Windows startup. Monotonic timers are appropriate for periodic work during an active Linux manager, but they are not substitutes for wall-clock events when a machine may be suspended or the distro may be stopped.
WSL can stop independently of systemd
Microsoft explicitly documents that systemd services do not keep a WSL instance alive. WSL can be shut down explicitly, stopped as part of servicing, or simply not launched after a Windows restart. A systemd timer cannot fire while its manager is absent. A user can verify what is happening by checking systemctl is-system-running inside the distro and wsl.exe --list --running from Windows, but those commands only describe the current state; they do not make it persistent.
This boundary should shape the service contract. If a job is useful only during the next development session, let a persistent timer catch it up at distro startup. Make output idempotent and store durable state in a location that survives distro shutdown. Do not use frequent timers to simulate a permanent service, keep-awake loop, or Windows Task Scheduler. If a job must run at a specific time regardless of whether a terminal is open, schedule the launch outside WSL.
Starting work from Windows Task Scheduler
Windows Task Scheduler can start a distro by invoking wsl.exe. A basic action, configured under the same Windows account that owns the distribution, can run:
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/systemctl start refresh-local-index.service
The task should have an explicit Windows trigger, conditions, retry policy, and account/logon behavior. WSL distributions are registered per Windows user, so a task running as another account may not see the intended distro. The --user root option is used here because starting a system service normally requires root inside Linux; replace Ubuntu and the unit name with the installation’s exact values. Test the exact action interactively first, capture $LASTEXITCODE, and verify the Linux service log after invocation. Do not assume a scheduled task’s default working directory is the Linux project directory; set WorkingDirectory= on the systemd service instead.
Task Scheduler can ask WSL to start when it is not running, but it does not transform WSL into a host-managed Linux server. Windows logoff, shutdown, policy, or power state can still affect the job, and a long-running process remains bounded by the Windows/WSL lifecycle. If the task must execute while nobody is signed in or needs a durable service-level availability guarantee, use a host or service designed and managed for that requirement. Where the schedule’s owner is an interactive user, ensure Task Scheduler is allowed to run it under the intended account and test startup after a real Windows reboot, not only during an existing login session.
Operational checks and acceptance criteria
Use systemd-analyze calendar '*-*-* 07:30:00' to validate the calendar expression and inspect its normalized next occurrence. Use systemctl show refresh-local-index.timer -p LastTriggerUSec -p NextElapseUSecRealtime where those properties exist in the installed systemd version; otherwise use the human-readable list-timers output. After a test run, verify all three facts: the timer is loaded and enabled, the service ran with the intended user and working directory, and the expected durable output changed exactly once.
Test the WSL boundary intentionally. Start the distro, confirm the timer, then stop the distro using the planned maintenance procedure. Relaunch it after a simulated missed calendar occurrence and confirm whether one catch-up execution is acceptable. For a Windows-scheduled launch, test from the actual scheduled-task account and conditions, not only from an administrator PowerShell session. Include a timeout and observable exit status in an external wrapper if the job can hang; an enabled WSL distro is not an infinite retry mechanism.
For troubleshooting, separate four conditions: the distro never launched; systemd did not start; the timer was not enabled or parsed; or the triggered service failed. journalctl -b and systemctl --failed help with the first Linux boot after startup, while unit-specific status and logs isolate a job failure. Validate paths and permissions under the unit’s configured user. A shell command that works from an interactive terminal can depend on a profile, PATH, environment variable, or current directory that a system service does not inherit.
The useful mental model is two schedulers with different scopes: systemd schedules work inside a live distro, while Windows scheduling can request that the distro launch. Use Persistent=true to make a calendar timer recover a missed occurrence after manager restart, but use an external launch trigger when the distro might not be running at the required time.
Related:
- How to Enable systemd in WSL2
- Systemd in WSL: Service Lifetime, Idle Shutdown, and the Limits of a Workstation VM
Sources: