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

MechanismOverlap protectionTimeoutMissed-tick behaviorSecrets
cronnone (add flock)none (add timeout)skipped silentlysourced file, chmod 600
systemd timerbuilt inTimeoutStartSecruns on wake if Persistent=trueEnvironmentFile=
launchdnone (add flock)none (add timeout)varies by interval typewrapper script + file, avoid the plist itself
CI runnerusually one queued run at a time per workflowtimeout-minutesprovider-dependent, can be delayed under loadplatform 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.