Back to guides

How Do I Schedule Claude Code to Run Maintenance Jobs Overnight?

Jake McCluskeyIntermediate30 min read
How Do I Schedule Claude Code to Run Maintenance Jobs Overnight?

Claude Code is most useful when you're watching it. It's most valuable when you're not. A scheduled overnight job can triage the inbox of bugs, regenerate the changelog from yesterday's commits, and file three draft PRs for lint errors before you sit down with coffee. Here's the minimum-viable setup that runs Claude Code unattended, reliably, starting tonight.

Why this matters

Most developers use Claude Code in a tight feedback loop — type, watch, correct, repeat. That's the right mode for building. But it leaves the long tail of maintenance work on your plate: weekly dep bumps, stale issue triage, refactoring a pattern across the codebase, regenerating documentation from the source.

That work is perfect for a scheduled job. No human latency, no context switch cost, and Claude doesn't get bored on iteration #47. The constraint is that Claude has to behave without a human in the loop, which means you need three things: a deterministic prompt, strict quality gates, and a way to catch failures. This guide sets up all three.

Before you start

You need:

  • Claude Code installed and authenticated on the machine that'll run the schedule. A laptop works for a week; for real ongoing use, a dedicated VM or your CI runner is better.
  • An API-plan or subscription that permits headless runs. Check your plan's current terms — scheduled automation is usually fine but you want to read before you run.
  • A concrete overnight task. "Triage any new issue labeled bug opened since yesterday and suggest a reproduction" is a great first one.
  • A working CLAUDE.md and hooks setup in the target repo — see Write a CLAUDE.md That Works and Claude Code Hooks. These are your safety rails when you're asleep.

Step 1: Write the job as a single headless command

Claude Code supports non-interactive runs with --print (or -p), which takes a prompt, runs to completion, and exits. The shape:

bash
claude --print --dangerously-skip-permissions "$(cat <<'PROMPT'
You are running unattended.

Task: Look at all GitHub issues in this repo opened or updated
in the last 24 hours that are labeled "bug" and have no
assignee. For each one:

1. Read the issue body.
2. Check the relevant code path.
3. Append a comment with:
   - a one-line summary of the likely root cause
   - a minimal reproduction if you can produce one
   - which file and line to start debugging

Do NOT close issues. Do NOT make code changes. Do NOT assign
yourself. Only post comments.

When done, write a one-line summary to STDOUT.
PROMPT
)"

Two critical things:

  • Explicit constraints. "Do NOT close issues." etc. Claude doesn't have a human to check with, so ambiguity becomes errors.
  • Hard exit criteria. "When done, write a one-line summary." No dangling "and anything else you think of."

Save this as scripts/nightly-triage.sh.

--dangerously-skip-permissions bypasses Claude's interactive approval prompts. Only use it for prompts where you've reviewed and trust every action path. Consider a narrower permissions config file instead — see the Claude Code docs for per-tool allow/deny rules.

Step 2: Wrap the script with logs and error handling

scripts/run-nightly.sh:

bash
#!/usr/bin/env bash
set -uo pipefail

cd /path/to/your/repo
TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ)
LOG_DIR="$HOME/claude-nightly-logs"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/$TIMESTAMP.log"

# Pull latest so Claude reasons against current code
git fetch --all --quiet
git checkout main --quiet
git pull --ff-only --quiet >> "$LOG" 2>&1

# Run the job, tee output to the log
./scripts/nightly-triage.sh 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}

if [ $EXIT_CODE -ne 0 ]; then
  # Optional: notify a webhook, email, or Slack on failure
  curl -s -X POST "$NIGHTLY_FAILURE_WEBHOOK" \
    -d "Nightly triage failed. Log: $LOG" || true
fi

exit $EXIT_CODE

Logs in a known location, non-zero exit on failure, an optional failure notification. That's the minimum for "I'm asleep when this runs."

Step 3: Schedule it

On macOS or Linux, cron:

bash
crontab -e

Add a line:

text
0 3 * * * /path/to/scripts/run-nightly.sh > /dev/null 2>&1

Runs 3am every day. Redirect stdout/stderr to null because run-nightly.sh already handles logging.

For server deployments, prefer systemd timers or your platform's scheduler (GitHub Actions schedule, Railway cron, Fly machines, etc.) over host cron — they're easier to debug and version-control.

Step 4: Add quality gates in the job

Remember: nobody's watching. If Claude hallucinates a file edit that breaks main, you find out when the next human tries to commit.

For any job that modifies code (vs. just commenting on issues), gate behind a full test pass:

bash
npm test -- --run
TEST_EXIT=$?

if [ $TEST_EXIT -ne 0 ]; then
  echo "Tests failed — reverting Claude's changes"
  git reset --hard HEAD
  exit 1
fi

Combined with your Claude Code hooks from the hooks guide, this gives you: hooks block bad commits in the Claude session, and the wrapper script blocks bad branches from being pushed.

For jobs that push changes, open a PR instead of pushing to main:

bash
BRANCH="nightly/$(date -u +%Y%m%d)"
git checkout -b "$BRANCH"
# Claude's work happens here
git push origin "$BRANCH"
gh pr create --title "Nightly automated triage" --body "Generated by scheduled Claude run on $(date -u)"

You review and merge in the morning, or not — the job ran harmlessly either way.

Step 5: First run — kick it off manually

Do not wait for 3am to find out it's broken. Run it now:

bash
./scripts/run-nightly.sh

Watch the log. Iterate on the prompt until:

  • Claude completes the task.
  • Exit code is 0.
  • Output looks right.

Then let the schedule take over.

Verify it worked

1. Log file exists for the most recent scheduled run. Check $HOME/claude-nightly-logs/ — each run should produce a timestamped file.

2. Artifacts landed. If the job posts GitHub comments, they should be there with yesterday's timestamp. If the job opens PRs, they should exist.

3. Failures notify you. Deliberately break the prompt (e.g., give Claude an impossible instruction), run manually, confirm you get the failure notification.

Where this breaks

  • API rate limits or subscription caps. A chatty scheduled run can exhaust your daily limit. Track token usage in the log, and if you're bumping against limits, switch to the Batch API for jobs that aren't time-sensitive — it's half the cost.
  • Silent prompt drift. A prompt that worked in January may misbehave in April because dependencies changed, the codebase shape shifted, or Claude's default behavior updated. Review logs weekly and re-test manually once a month.
  • Authentication expiring. Your Claude Code session auth can expire. If the schedule runs for 30 days without you logging in, it may suddenly start failing with auth errors. Use an API-key-based setup for production jobs, not session auth.
  • The machine isn't on at 3am. If cron is on your laptop and the lid is closed, nothing runs. For real production use, schedule on an always-on machine — GitHub Actions, a cloud VM, or your company's CI runner.
  • --dangerously-skip-permissions doing something dangerous. That flag grants Claude every permission. A prompt with ambiguous scope ("clean up the repo") can become "delete everything." Always pair the flag with very explicit prompts and hard quality gates.

What to try next

Want this built for you instead?

Let's talk about your AI + SEO stack

If you'd rather skip the how-to and have it shipped for you, that's what I do. Start a conversation and we'll figure out the fastest path to results.

Let's Talk
Questions from readers

Frequently asked

Does my laptop need to be on for the schedule to run?

For local cron, yes — a closed laptop doesn't fire jobs. For production use, schedule on an always-on machine: a cheap cloud VM, GitHub Actions schedule, or your company's CI runner. The schedule lives where the always-on runtime lives.

Is it safe to use --dangerously-skip-permissions?

Only when the prompt is explicit about what's allowed and you have strong quality gates downstream. For prompts like 'clean up the repo' it's unsafe — the scope is too ambiguous. For tightly scoped prompts with hooks and PR-based landing, it's how most unattended workflows are built.

How do I keep unattended runs from pushing broken code?

Land changes as PRs, not direct pushes to main. Run full tests in the wrapper script and git reset --hard on failure. Between CI on the PR and tests in the wrapper, broken code has three chances to fail before landing.

What's a good first overnight job?

Issue triage that only comments — no code changes, no closing. It's high-value (someone else was going to do this manually) and low-risk (worst case, a bad comment gets deleted). Graduate to code changes once you trust the prompt.

How do I know if a scheduled run failed?

Log to a timestamped file, capture the exit code, and POST to a webhook on non-zero exit. A Slack webhook or an email hook is enough. Without active failure notification, you'll find out days later that the schedule's been silently broken.