Scheduling Claude Code: cron, systemd timers, launchd, and CI runners compared
Four ways to make claude -p run on a schedule with nobody watching,
each with a different answer to overlap protection, timeouts, logging, and secrets. None of them
is universally right — the right one is usually whichever scheduler your host already runs, but
it's worth knowing what you're giving up by defaulting to the one you happen to already know.
On this page
cron
The lowest-common-denominator choice — present on essentially every Linux box, simplest possible syntax, no unit files to write. Its gaps are exactly what it doesn't do for you:
# crontab -e
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * . /etc/agent/secrets.env && flock -n /tmp/nightly.lock \
claude -p "Deploy release to prod" --mcp-config /etc/agent/mcp.json \
>> /var/log/agent/nightly.log 2>&1
No overlap protection (bring your own flock), no timeout (bring your own
timeout), a minimal PATH that isn't your login shell's, and logging that
defaults to mailing stdout/stderr to the crontab owner if mail happens to be configured — none of
which is a logging strategy. See running claude -p on cron for
the full checklist (locking, MCP_TOOL_TIMEOUT, secrets, wake-up patterns) if this is
where you're landing; it's the right choice mainly because it's already there, not because it's
the most capable option below.
systemd timers
On any host already running systemd, a timer unit trades cron's simplicity for overlap protection and structured logging built in, at the cost of two files instead of one line:
# /etc/systemd/system/claude-nightly.service
[Unit]
Description=Nightly Claude Code deploy job
[Service]
Type=oneshot
EnvironmentFile=/etc/agent/secrets.env
ExecStart=/usr/local/bin/claude -p "Deploy release to prod" --mcp-config /etc/agent/mcp.json
TimeoutStartSec=1800
Restart=no
# /etc/systemd/system/claude-nightly.timer
[Unit]
Description=Run claude-nightly.service at 3am
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now claude-nightly.timer
journalctl -u claude-nightly.service --since today
systemd refuses to start a second instance of a unit that's still running, so the overlap
protection flock exists to bolt onto cron comes for free here. TimeoutStartSec
is your outer ceiling instead of a separate timeout wrapper, and
EnvironmentFile= reads secrets from a file without a shell-sourcing step in the
command line itself. Persistent=true is worth calling out specifically: it means a
tick missed because the machine was off or asleep runs once as soon as the machine is back, rather
than silently skipping — behavior cron doesn't have an equivalent for at all.
launchd
macOS's scheduler, configured with a plist instead of a crontab line or unit file — the thing to know going in is that a "load" doesn't run the job immediately, and unloading/reloading is how you pick up an edit:
<!-- ~/Library/LaunchAgents/dev.example.claude-nightly.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.example.claude-nightly</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/claude</string>
<string>-p</string>
<string>Deploy release to prod</string>
<string>--mcp-config</string>
<string>/Users/you/agent/mcp.json</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>EnvironmentVariables</key>
<dict>
<key>ANTHROPIC_API_KEY</key><string>sk-ant-...</string>
</dict>
<key>StandardOutPath</key><string>/tmp/claude-nightly.log</string>
<key>StandardErrorPath</key><string>/tmp/claude-nightly.log</string>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/dev.example.claude-nightly.plist
launchctl start dev.example.claude-nightly # trigger once immediately, for testing
launchctl unload ~/Library/LaunchAgents/dev.example.claude-nightly.plist # after editing
A literal secret sitting in a plist on disk is a worse default than an EnvironmentFile=
elsewhere — prefer having ProgramArguments invoke a wrapper script that sources a
permissioned secrets file itself, rather than putting the key directly in
EnvironmentVariables as shown above. There's no built-in overlap protection or hard
timeout at the plist level either — flock and timeout inside the wrapper
script carry over from the cron approach unchanged.
CI runners
GitHub Actions (or any CI provider with a cron trigger) gets you a disposable, sandboxed environment and a secrets store for free — the tradeoff is that you're now dependent on the CI provider's own scheduling reliability and its cold-start latency each run:
# .github/workflows/claude-nightly.yml
name: Nightly Claude Code deploy
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- run: npm install -g @anthropic-ai/claude-code
- run: claude -p "Deploy release to prod" --mcp-config .mcp.json --output-format json
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
timeout-minutes is the outer ceiling, secrets come from the platform's own
encrypted store rather than a file on a box you maintain, and every run gets a fresh container —
no stale state to worry about between ticks. workflow_dispatch alongside the
schedule is worth keeping even for a scheduled-only job: it gives you a manual "run it now" button
for testing without waiting for the next tick. The real cost is scheduling precision — GitHub's
own docs are explicit that schedule triggers can be delayed during periods of high
load, so a job that genuinely needs to fire within a minute or two of 3am is a worse fit here than
on a host you control.
Side by side
| Mechanism | Overlap protection | Timeout | Missed-tick behavior | Secrets |
|---|---|---|---|---|
| cron | none (add flock) | none (add timeout) | skipped silently | sourced file, chmod 600 |
| systemd timer | built in | TimeoutStartSec | runs on wake if Persistent=true | EnvironmentFile= |
| launchd | none (add flock) | none (add timeout) | varies by interval type | wrapper script + file, avoid the plist itself |
| CI runner | usually one queued run at a time per workflow | timeout-minutes | provider-dependent, can be delayed under load | platform secrets store |
Logging and secrets, across all four
The pattern that holds regardless of scheduler: prefer --output-format json (or
stream-json) over plain text if anything downstream needs to parse what happened, and
never let a secret live as a literal argument on the command line any of these four mechanisms
invoke — ps can expose command-line arguments to other local users on cron and
launchd hosts, and a CI provider's own job logs can echo an argument back verbatim if you're not
careful. An environment variable sourced from a permissioned file (cron, launchd) or a
platform secrets store (systemd's EnvironmentFile=, CI's encrypted secrets) keeps the
key out of both the process list and the log line that invoked it.
The one thing to keep consistent regardless
Whichever scheduler ends up running the job, have the wrapper script branch on the same exit
codes every time rather than reinventing the convention per scheduler: 0 for success,
75 for "temporary failure, retry next tick," anything else for "stop and page a
human." That convention is what lets you swap cron for systemd, or a CI runner for a bare VM,
without also rewriting whatever's downstream deciding whether to retry. See
exit codes for unattended agents for where the 75/1 split
comes from and how to wire a wrapper around it.