Claude Code Cron Job Setup: Run Automations Overnight

To set up a Claude Code cron job, you need two pieces. The first is a wrapper script that sets PATH, loads a token, cds into your project and runs claude -p with explicit permissions and limits. The second is one crontab line that runs that script under a lock and sends its output to a log file. Most "silent" overnight failures come from what cron does not give you: your shell's environment, your login, your project directory and a place to see errors. This guide covers each of those gaps, plus the scheduling options Anthropic offers if you don't want to manage cron yourself.
Should you use cron, or a built-in Claude Code scheduler?
Use cron when the job needs your machine's local files and tools and you want full control. Use Anthropic's built-in options when you'd rather not maintain a server. Claude Code now has three of its own scheduling options, and the official comparison shows how they differ:
| Option | Runs on | Machine must be on | Local files | Minimum interval | Main limitation |
|---|---|---|---|---|---|
System cron + claude -p | Your machine/server | Yes | Yes | 1 minute (cron) | You handle auth, env, logs, locking |
| Routines (cloud) | Anthropic cloud | No | No (fresh clone) | 1 hour | Research preview; GitHub repos only |
| Desktop scheduled tasks | Your machine | Yes, app open and awake | Yes | 1 minute | Skipped while the computer sleeps |
/loop | Your open session | Yes | Yes | 1 minute | Session-scoped, expires after 7 days |
How to choose:
- Routines suit you if your work lives in GitHub and should run while your laptop is closed. They're available on Pro, Max, Team and Enterprise plans (source, as of October 2026). Not for you if the job needs files outside a repo, local databases, or tools you can't install from a setup script. The docs also note that routines are in research preview and that a green run status "does not mean the task in your prompt succeeded" (source).
- Desktop scheduled tasks suit people who keep the Claude Desktop app open on a work machine. Not for you if the machine sleeps overnight. If it sleeps through a scheduled time, the run is skipped, and on wake only one catch-up run happens (source).
/loopis for polling during a session you have open. Recurring tasks expire seven days after you create them (source), so it can't replace cron.- Cron fits an always-on Linux box, VPS or home server where you want plain files, plain logs and no extra platform.
The rest of this guide is about the cron route.
Trap 1: authentication that works in your terminal but not in cron
Your cron job needs a credential passed in explicitly through an environment variable, because the login in your interactive terminal may not reach it. Claude Code's authentication docs list two common options for scripts:
- Subscription token: run
claude setup-tokenonce. It opens the browser flow and prints a one-year OAuth token without saving it anywhere. Set that token asCLAUDE_CODE_OAUTH_TOKEN. It requires a Pro, Max, Team or Enterprise plan (source, as of October 2026), and it can only make model requests, so claude.ai connectors won't work with it. - API key: set
ANTHROPIC_API_KEYto a key from the Claude Console. Usage is billed to your API account instead of your subscription. In-pmode, the key "is always used when present" (source).
Two details often cause trouble:
- Precedence.
ANTHROPIC_API_KEYranks aboveCLAUDE_CODE_OAUTH_TOKEN(source). If both exist, you are billed through the API key, which may not be what you intended. --bareignores the OAuth token. Bare mode is recommended for scripts, but it "does not readCLAUDE_CODE_OAUTH_TOKEN". With--bare, useANTHROPIC_API_KEYor anapiKeyHelperinstead (source).
Why this fails silently: when a failure happens inside the run, "such as missing authentication, Claude Code prints the failure as the result on stdout" (source). If you throw stdout away, the error disappears with it. Check the exit code too: Claude Code exits with a non-zero code when a run fails.
Trap 2: PATH and the minimal cron environment
Cron runs your command in a much smaller environment than your terminal, so set every variable the job needs inside the script itself. The crontab(5) man page says cron sets SHELL to /bin/sh and takes LOGNAME and HOME from /etc/passwd. Anything your ~/.zshrc or ~/.bashrc adds is not guaranteed to be there.
That matters for Claude Code because the native installer puts the launcher at ~/.local/bin/claude (source), and that directory is often added to PATH only by your shell profile. The tools Claude calls during the run also need to be found: node, git, gh, npm and so on.
Practical fixes:
- Use the absolute path to
claude(runwhich claudein your terminal to find it), or export a fullPATHat the top of the script. - Keep secrets in a dedicated env file with
chmod 600, and load it in the script. Don't paste them into the crontab. - Watch for
%in crontab lines. Unless escaped, cron turns it into a newline and sends the rest of the line to standard input (source). That breaksdate +%Finline, so put date formatting in the script. - Cron uses the system timezone unless you set
CRON_TZ(on cron implementations that support it, such as cronie, per the crontab(5) man page).
Trap 3: the working directory decides what Claude loads
Always cd into the project explicitly, because the folder a -p run starts in decides which configuration and instructions it loads. Without --bare, claude -p "loads the same context an interactive session would, including anything configured in the working directory or ~/.claude". That includes CLAUDE.md, the hooks in .claude/settings.json and the MCP servers in .mcp.json (source).
Two consequences:
- Wrong directory, wrong behavior. If the job starts somewhere else, Claude won't see your project's
CLAUDE.mdor settings, and relative paths in your prompt point to the wrong files. - No trust dialog. A
-psession "shows no workspace trust dialog and no per-server approval prompt", so project hooks and MCP servers run even in a folder you've never trusted (source). Only point cron at repositories you control.
If you want the same result on every run no matter what's in ~/.claude, use --bare and pass context explicitly with --append-system-prompt-file, --settings and --mcp-config (source). The trade-off is that you then need an API key (see Trap 1).
Trap 4: permissions in a run nobody is watching
Set the permission mode and the allowed tools explicitly, because no one is awake to answer a prompt. The docs warn that a run with no permission mode set takes a built-in starting mode, "which can be auto, so pass the one you want" (source).
Options from the headless docs and the CLI reference:
--permission-mode dontAskdenies anything that would prompt, while reads and your--allowedToolsentries still run. This is a good default for locked-down jobs.--allowedTools "Read,Edit,Bash(npm test )"grants narrow, prefix-matched commands. The space beforematters:Bash(git diff*)would also matchgit diff-index.--permission-prompts nonetells Claude that nobody can approve requests and not to retry denied ones. It requires v2.1.259 or later.--max-turnscaps agentic turns. There is no limit by default.--max-budget-usdstops the run at an estimated spend. It's a client-side estimate, and spend can go past the cap, so leave headroom.
Avoid --dangerously-skip-permissions for overnight jobs unless the machine is a disposable sandbox.
Putting it together: script, crontab, lock and log rotation
The setup has four parts: a wrapper script, a crontab line with flock, structured logs, and rotation so the logs don't fill your disk. Everything below is an example. Change the paths, prompt and tool list to fit your project.
Step 1: the wrapper script
#!/usr/bin/env bash
# ~/bin/claude-nightly.sh (example)
set -uo pipefail
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
set -a; source "$HOME/.config/claude-cron/env"; set +a # holds CLAUDE_CODE_OAUTH_TOKEN=...
unset ANTHROPIC_API_KEY # avoid accidental precedence over the token
cd "$HOME/projects/myapp" || { echo "cd failed"; exit 1; }
LOG_DIR="$HOME/logs/claude-cron"
mkdir -p "$LOG_DIR"
STAMP=$(date +%Y-%m-%dT%H%M)
OUT="$LOG_DIR/run-$STAMP.json"
claude -p "$(cat .claude/nightly-prompt.md)" \
--permission-mode dontAsk \
--permission-prompts none \
--allowedTools "Read,Edit,Bash(npm test *),Bash(git status *),Bash(git diff *)" \
--max-turns 30 \
--max-budget-usd 3 \
--output-format json > "$OUT" 2> "$LOG_DIR/run-$STAMP.err"
STATUS=$?
echo "$(date -Is) exit=$STATUS cost=$(jq -r '.total_cost_usd // "n/a"' "$OUT" 2>/dev/null)"
[ "$STATUS" -eq 0 ] || echo "FAILED: $(jq -r '.result // empty' "$OUT" 2>/dev/null | head -c 500)"
exit "$STATUS"
Why these choices: --output-format json gives you result, session_id and total_cost_usd in a form a script can parse (source). Branching on the exit code catches failed runs. Keeping the prompt in a file means you can version-control it.
Step 2: the crontab line
Run crontab -e:
SHELL=/bin/bash
MAILTO=""
7 2 * * * /usr/bin/flock -n /tmp/claude-nightly.lock $HOME/bin/claude-nightly.sh >> $HOME/logs/claude-cron/cron.log 2>&1
flock -n makes the job "fail rather than wait if the lock cannot be immediately acquired" (flock(1)). If last night's run is still going, a second copy won't start on top of it. Cron mails command output to the crontab owner or to MAILTO (cron(8)). Since this setup sends output to a file, set MAILTO="" so cron doesn't also try to send mail.
Step 3: log rotation
Rotate the summary log with logrotate, and prune the per-run files with find. An example /etc/logrotate.d/claude-cron:
/home/you/logs/claude-cron/cron.log {
weekly
rotate 8
compress
missingok
notifempty
copytruncate
}
copytruncate truncates the file in place after copying it, so the >> in your crontab keeps writing to the same file. missingok and notifempty skip missing or empty logs (logrotate(8)). For the per-run JSON files, add a second cron line such as 0 4 0 find $HOME/logs/claude-cron -name 'run-*' -mtime +30 -delete.
Claude Code also saves session transcripts on disk. If you never resume these runs, --no-session-persistence stops them from being saved (source).
Step 4: test it under cron conditions
Before you trust the schedule, run the script in an environment as bare as cron's: env -i HOME="$HOME" /bin/sh -c '$HOME/bin/claude-nightly.sh'. Then schedule it a few minutes ahead and read cron.log and the JSON file. Also run claude auth status inside the script once: it reports which authMethod is active and exits 1 if you're not logged in (source).
Keeping overnight jobs healthy over time
Plan for updates, token expiry and alerting from the start, because these cause failures weeks after setup rather than on day one.
- Updates: native installs update automatically in the background (source). If you want a predictable version for scheduled jobs, set
"autoUpdatesChannel": "stable", or setDISABLE_AUTOUPDATER=1in your settings and update yourself. - Token expiry: the
setup-tokentoken lasts one year (source). Put a renewal reminder in your calendar. - Alerting: a failing exit code that only reaches a log file is still silent. For example, you could add a line to the script that sends the
FAILED:message to a Slack webhook or a health-check service when the run fails. - Read the output, not just the status: as with routines, a clean exit means the process finished, not that the task was done well. Check the
resulttext now and then.
FAQ
Can Claude Code run on a schedule without my computer on?
Yes, through Routines, which run on Anthropic-managed cloud infrastructure on hourly, nightly or weekly schedules (source). They work from a fresh clone of your GitHub repositories, so they can't see local files. The minimum interval is one hour, and the feature is in research preview.
Why does my Claude Code cron job work manually but not from cron?
Usually cron's environment is missing something your terminal has: ~/.local/bin on PATH, an exported token, or the project directory. Use absolute paths, load credentials from a file, and cd explicitly. Capture stdout, because Claude Code prints failures such as missing authentication there (source).
Should I use CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY for cron?
Use CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token) if you want runs to count against your Claude subscription. Use ANTHROPIC_API_KEY if you want API billing, or if you run with --bare, which ignores the OAuth token (source). Don't set both, because the API key takes precedence.
How do I stop a scheduled Claude run from costing too much?
Combine --max-turns with --max-budget-usd, and log total_cost_usd from the JSON output. The budget cap uses a client-side estimate and spend can go past it, so set it below your real limit (source).
How do I prevent overlapping Claude cron runs?
Wrap the command in flock -n with a lock file. A new run then exits right away if the previous one still holds the lock (flock(1)).
Sources
- Run Claude Code programmatically (headless) – Claude Code Docs
- CLI reference – Claude Code Docs
- Authentication – Claude Code Docs
- Advanced setup – Claude Code Docs
- Run prompts on a schedule – Claude Code Docs
- Automate work with routines – Claude Code Docs
- Schedule recurring tasks in Claude Code Desktop – Claude Code Docs
- crontab(5) – Linux manual page
- cron(8) – Debian manpages
- flock(1) – Linux manual page
- logrotate(8) – Linux manual page