How Do I Run Scheduled Jobs in a Sandbox With systemd Timers?
Updated October 27, 2026 .md
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 and pause and resume 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:
# /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'# /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# /etc/systemd/system/tick-mono.timer: 2 minutes after the last run
[Timer]
OnActiveSec=2min
OnUnitActiveSec=2min
Unit=tick@mono.service
[Install]
WantedBy=timers.targetWe 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:
sudo systemctl daemon-reload
sudo systemctl enable --now tick-cal.timer tick-mono.timercron 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:
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:
cron 10:44:01
cal 10:44:03
cron 10:46:01
cal 10:46:03
mono 10:46:03The 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.
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#
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"{"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.serviceEverything ran within a second of the resume, and the calendar job ran twice. The journal shows why:
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:
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.serviceThe 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 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 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:
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"{"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.servicetick-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=truetimer 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.
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:
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}'{"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.
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:
#!/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/nulloutside 11:07:03
outside 11:07:14
real 0m3.783sThe 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), 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#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.