# How Do I Run Scheduled Jobs in a Sandbox With systemd Timers?

> Use a systemd timer with OnCalendar and Persistent=true, or cron. Neither fires while the sandbox is stopped or idle-paused. On a memory resume, missed wall-clock jobs run right away; elapsed-time timers don't count the stopped time. On a cold boot, only Persistent=true catches up. For exact schedules, keep it awake or schedule from outside.

Source: https://sandboxer.com/guides/run-scheduled-jobs-in-a-sandbox-with-systemd-timers. Updated 2026-10-27.

**Short answer:** Use a systemd timer with OnCalendar and Persistent=true, or cron. Neither fires while the sandbox is stopped or idle-paused. On a memory resume, missed wall-clock jobs run right away; elapsed-time timers don't count the stopped time. On a cold boot, only Persistent=true catches up. For exact schedules, keep it awake or schedule from outside.

A stopped sandbox has no running CPU and no running clock, so nothing inside it can fire. What matters is what each kind of schedule does when the sandbox comes back. We measured that on prod on October 27, 2026, with one `small` sandbox, three timers and a crontab, across two memory resumes and one cold boot.

## At a glance

| Schedule | While stopped or paused | After a memory resume | After a cold boot |
| - | - | - | - |
| `OnCalendar=` with `Persistent=true` | doesn't fire | runs at once, sometimes twice | runs once at boot |
| `OnCalendar=` without `Persistent=` | doesn't fire | not tested | missed runs are skipped |
| `OnUnitActiveSec=` (elapsed time) | doesn't fire | may not count the stopped time (about 60 s counted in our runs) | starts counting again from boot |
| cron `*/2 * * * *` | doesn't fire | runs at once, one to three times | missed runs are skipped |
| Your own scheduler outside, calling `sbx resume` + `sbx exec` | fires on time | n/a | n/a |

The rest of this page shows the runs behind each cell. For what pausing and waking are, see [idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use) and [pause and resume with memory](/guides/pause-and-resume-a-sandbox-with-memory).

## Set up a timer and a cron job

One template service writes a line per run, so every timer logs to the same file:

```ini
# /etc/systemd/system/tick@.service
[Unit]
Description=Log one tick for %i

[Service]
Type=oneshot
User=user
ExecStart=/bin/sh -c 'echo "%i $(date -u +%%H:%%M:%%S)" >> /home/user/ticks.log'
```

```ini
# /etc/systemd/system/tick-cal.timer: wall clock, every 2 minutes, with catch-up
[Timer]
OnCalendar=*:0/2
Persistent=true
Unit=tick@cal.service

[Install]
WantedBy=timers.target
```

```ini
# /etc/systemd/system/tick-mono.timer: 2 minutes after the last run
[Timer]
OnActiveSec=2min
OnUnitActiveSec=2min
Unit=tick@mono.service

[Install]
WantedBy=timers.target
```

We wrote the three files with `sudo tee` from a local script piped in with `sbx exec -i $id "bash -s" < setup.sh`, then enabled them:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now tick-cal.timer tick-mono.timer
```

cron isn't installed in the base image. Installing it enables the service, but the image's policy blocks package scripts from starting it, so start it yourself:

```bash
sbx exec $id --timeout 300 "sudo apt-get update -qq; sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cron"
sbx exec $id "sudo systemctl start cron"
echo '*/2 * * * * echo "cron $(date -u +\%H:\%M:\%S)" >> /home/user/ticks.log' | sbx exec -i $id "crontab -"
```

Three minutes later:

```text
cron 10:44:01
cal 10:44:03
cron 10:46:01
cal 10:46:03
mono 10:46:03
```

The elapsed-time timer was due at 10:45:38 and ran at 10:46:03. That's systemd's default `AccuracySec=` of one minute, which lets it batch wakeups; set `AccuracySec=1s` if you need the exact second. See [systemd.timer](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html).

To read a unit's log, use `journalctl -u tick@cal.service`. That works without sudo in sandboxes created after 11:17 UTC on October 27, 2026. Between 10:44 and 11:17 UTC that day it needs `sudo`; in older sandboxes, including the one on this page, use `sudo journalctl -m -u tick@cal.service`.

## While stopped: nothing runs

We stopped the sandbox with `sbx stop $id --wait` at 10:47:33 and resumed it at 10:55:14. Four calendar slots (10:48 to 10:54) and three cron slots passed while it was stopped. None of them ran at their time. The log has nothing between 10:46:03 and the resume.

An idle pause is the same stop, so this holds for a sandbox that paused itself too. A timer firing inside a paused sandbox can't wake it, because nothing inside is running. Only an API call, a hosted-URL request or `sbx ssh` wakes it.

## After a memory resume

```bash
sbx resume $id --json | tail -1 | jq -c '.sandbox | {state, memoryRestored, resumeReason}'
sbx exec $id "date -u +%T; cat ~/ticks.log; systemctl list-timers 'tick-*' --no-pager | head -3"
```

```text
{"state":"ready","memoryRestored":true,"resumeReason":null}
10:55:15
…
mono 10:46:03
cron 10:55:14
cal 10:55:14
mono 10:55:14
cal 10:55:14
NEXT                            LEFT LAST                        PASSED UNIT            ACTIVATES
Tue 2026-10-27 10:56:00 UTC      44s Tue 2026-10-27 10:55:14 UTC 1s ago tick-cal.timer  tick@cal.service
Tue 2026-10-27 10:57:14 UTC 1min 58s Tue 2026-10-27 10:48:30 UTC 1s ago tick-mono.timer tick@mono.service
```

Everything ran within a second of the resume, and the calendar job ran twice. The journal shows why:

```text
Oct 27 10:48:30.759019 lazy-solar-river systemd[1]: Starting tick@cal.service - Log one tick for cal...
Oct 27 10:55:14.529484 lazy-solar-river systemd[1]: Finished tick@cal.service - Log one tick for cal.
Oct 27 10:55:14.538921 lazy-solar-river systemd[1]: Starting tick@cal.service - Log one tick for cal...
```

In our runs the guest woke with its clock about a minute past the moment it stopped (10:48:30), not at the real time. The guest clock isn't NTP-synced, so it can start slightly behind or ahead before it's corrected. Jobs due in that first minute start. Milliseconds later the clock is corrected to 10:55:14, and every wall-clock schedule sees that it missed slots and runs again. The elapsed-time timer ran only because its deadline, 10:48:03, fell inside that first minute.

### Elapsed-time timers don't count the stopped time

We stopped again at 10:55:37, with the elapsed-time timer due at 10:57:14, and resumed at 11:00:42. Just before the stop, `cat /proc/uptime` read `319.51` at 10:55:35. Right after the resume it read `381.08` at 11:00:43, and the timers were:

```text
NEXT                            LEFT LAST                              PASSED UNIT            ACTIVATES
Tue 2026-10-27 11:01:20 UTC      37s Tue 2026-10-27 10:48:30 UTC 1min 22s ago tick-mono.timer tick@mono.service
Tue 2026-10-27 11:02:00 UTC 1min 16s Tue 2026-10-27 11:00:42 UTC    919ms ago tick-cal.timer  tick@cal.service
```

The wall clock moved 308 seconds. Uptime, the monotonic clock these timers use, moved 61.6. The first stop gave the same result: 464 seconds of wall time, 61.5 of uptime. Elapsed-time timers may not count all of the stopped time. So the 10:57:14 run was pushed out to 11:01:20, and it didn't run at the resume. A job set to run "every 6 hours" with `OnUnitActiveSec=6h` can drift by the whole time the sandbox spent stopped. Use `OnCalendar=` for anything tied to the time of day. [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume#timers-after-a-memory-resume) has the same effect measured with `sleep`.

### cron caught up too

After the second resume the log had three `cron 11:00:42` lines and two `cal 11:00:42` lines. cron ran the 10:56 slot at the stale clock, then the 10:58 and 11:00 slots after the clock jumped. After the first, longer stop it ran only once. The [cron man page](https://manpages.ubuntu.com/manpages/noble/man8/cron.8.html) describes this clock-change handling: after a forward change of less than 3 hours, jobs from the skipped time run soon after the change.

**What to do:** make every scheduled job safe to run twice in a row. Have it take a lock (`flock`), or check a "last run" timestamp it writes, and exit early when it ran less than one interval ago.

## After a cold boot

A resize always boots from disk, so we used it to force one. We stopped at 11:01:04 and resumed at 11:06:34 on the next size up:

```bash
sbx resume $id --type default --json | tail -1 | jq -c '.sandbox | {state, type, memoryRestored, resumeReason}'
sbx exec $id "cat /proc/uptime; tail -6 ~/ticks.log; systemctl is-active cron; systemctl list-timers 'tick-*' --no-pager | head -4"
```

```text
{"state":"ready","type":"default","memoryRestored":false,"resumeReason":"resized"}
1.23 2.74
cron 11:00:42
cron 11:00:42
cron 11:00:42
cal 11:00:42
cal 11:00:42
cal 11:06:35
active
NEXT                            LEFT LAST                           PASSED UNIT            ACTIVATES
Tue 2026-10-27 11:08:00 UTC 1min 23s Tue 2026-10-27 11:06:35 UTC 738ms ago tick-cal.timer  tick@cal.service
Tue 2026-10-27 11:08:00 UTC 1min 23s -                                   - tick-np.timer   tick@np.service
Tue 2026-10-27 11:08:35 UTC 1min 59s -                                   - tick-mono.timer tick@mono.service
```

`tick-np.timer` is a copy of the calendar timer without `Persistent=true`, added before this stop. Three slots (11:02, 11:04, 11:06) were missed:

- The `Persistent=true` timer ran once, a second after boot. systemd keeps its last run time in `/var/lib/systemd/timers/`, which is on the disk.
- The timer without `Persistent=` skipped them all and waits for 11:08.
- The elapsed-time timer started over: next run two minutes after boot.
- cron came back, because installing it enabled the service, but it didn't run the missed slots.

Timers and cron only come back after a cold boot because they're enabled units. See [which way of starting a service survives a cold boot](/guides/run-a-service-that-comes-back-after-resume).

## Keep it awake for exact schedules

If a job must run at its time, the sandbox has to be running at that time. Turn off both stops:

```bash
sbx idle $id 0                    # never pause for being idle
sbx extend $id --no-auto-stop     # no auto-stop deadline
sbx info $id --json | jq -c '.sandbox | {archiveAfter, idleStopMinutes}'
```

```json
{"archiveAfter":null,"idleStopMinutes":0}
```

A timer inside the sandbox doesn't count as activity, so with the idle timeout on, the sandbox pauses between runs. During the trial, `--no-auto-stop` and TTLs above eight hours are refused. See [long-running tasks](/docs/long-running#auto-stop).

An always-on sandbox is billed for every second it runs:

| Size | Per hour | 730 hours (a month) |
| - | - | - |
| `small` (2 vCPU, 4 GB) | $0.018 | $13.14 |
| `default` (4 vCPU, 8 GB) | $0.036 | $26.28 |

That's worth it when the jobs run often, or when something else in the sandbox has to stay up anyway. For a job that runs a few times a day, it's mostly paying for idle time.

## Schedule from outside instead

Keep the schedule on a machine that's always on, such as your own server's cron or a CI scheduler, and let the sandbox sleep between runs:

```bash
#!/bin/bash
# job.sh: needs SANDBOXER_API_KEY in the environment
set -euo pipefail
id=sb_yigyptxvgr
sbx resume "$id" --json >/dev/null || true      # already running is fine
sbx exec "$id" "echo \"outside \$(date -u +%H:%M:%S)\" >> ~/ticks.log; tail -2 ~/ticks.log"
sbx stop "$id" --wait --json >/dev/null
```

```text
outside 11:07:03
outside 11:07:14

real	0m3.783s
```

The whole resume, run and stop took 3.8 seconds, and the sandbox is billed only while it runs. `sbx exec` waits at most 600 seconds; for longer jobs use `sbx exec --detach` and poll it (see [long-running tasks](/docs/long-running#background-detached-commands)), and stop the sandbox when it's done. A job that must survive a cold boot shouldn't depend on processes left running from last time.

On your own machine, a line like `0 3 * * * /opt/jobs/job.sh` runs it nightly. That schedule line is an example; we ran the script by hand.

## Which to use

| Your job | Use |
| - | - |
| Runs while an agent is working anyway, a late run is fine | `OnCalendar=` + `Persistent=true` inside |
| Must run at a time of day, a few times a day | Your own scheduler outside, calling `sbx resume` + `sbx exec` |
| Runs every few minutes, around the clock | Keep the sandbox awake and use a timer inside |
| "Every N hours since the last run" | `OnCalendar=`, not `OnUnitActiveSec=`, unless the sandbox never stops |

## FAQ

### Do systemd timers run while a sandbox is stopped or paused?

No. Nothing runs inside a stopped sandbox, and a timer can't wake a paused one. Missed runs are handled when the sandbox comes back, as shown above.

### Does `Persistent=true` matter if the sandbox resumes with memory?

On a cold boot it's the only thing that makes a missed calendar job run. After a memory resume our `Persistent=true` timer caught up as well; we didn't test one without it across a memory resume.

### Why did my job run twice after a resume?

In our runs the guest woke with a stale clock, ran anything due then, and then the clock was corrected and wall-clock schedules caught up. Make jobs safe to run twice.

### Can cron wake a sandbox to run a job?

Not cron inside it. A cron job on another machine that calls `sbx resume` or the API can, and an API call also wakes an idle-paused sandbox by itself.

## Related

- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
- [Detect a cold boot after resume](/guides/detect-a-cold-boot-after-resume)
- [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume)
- [Long-running tasks](/docs/long-running)
