Skip to main content

Claude Code Headless Mode and Cron: Nightly Jobs on a VPS

· 20 min read
Yassine El Haddad
Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.

Quick Answer

claude -p "<prompt>" runs Claude Code without the interactive UI: it does the task, prints the result and exits. Put that command in a shell script, call the script from cron on a small Linux server, and you have an agent that works overnight. The safe version adds four things: a long-lived token in a chmod 600 file, --permission-mode dontAsk with a short allow list, --max-budget-usd and --max-turns caps, and --output-format json so every run leaves a log with its estimated cost. For web pages that block plain fetches, connect the Apify MCP server with an API token.

Most Claude Code guides stop at the interactive terminal. This one is about the jobs you want done before you sit down: a digest of what changed in the tools you depend on, a refreshed dataset, a report waiting in a folder. The example job below was run for real on 2026-10-21, and the flags, costs and failure modes in this post come from that run and from Anthropic's Claude Code documentation.

Let Claude Code set it up for you​

If you already have a server with Claude Code installed and signed in, paste this into an interactive claude session on that server. It builds the same nightly job this post walks through, asks before every file it writes, and does a capped dry run before it touches your crontab.

Set up my nightly Claude Code jobInteractive setupPaste into Claude Code

Run this inside an interactive claude session on your VPS. Claude interviews you, writes the job files, does a capped dry run, and only then adds the cron entry.

  1. Asks what to watch, when to run, and how you pay for Claude, one question at a time
  2. Checks claude, jq, flock and cron, and the Claude Code version
  3. Shows each file before writing it, and creates the secrets file with placeholders for you to fill in
  4. Runs one dry run with a $0.50 cap and shows the digest and the estimated cost
  5. Adds the crontab line only after you approve it
Help me set up a nightly headless Claude Code job on this Linux server.
The job reads a watchlist of release/changelog pages, finds versions that
are new since the last run, and writes a Markdown digest. Work step by
step and ask before every file you create or change.

1. Interview me, one question at a time, and wait for each answer:
- which pages to watch (changelog or GitHub releases URLs), and for
each one, why it matters to me (what to flag)
- what time the job should run, and confirm this server's timezone
with `timedatectl` or `date`
- how I pay for Claude in this job: a subscription token from
`claude setup-token` (Pro, Max, Team, Enterprise) or an Anthropic
API key. Explain the difference in two sentences.
- whether I have an Apify account for pages that block plain fetches.
If not, the job will use WebFetch only.
- the job folder (default ~/nightly-digest) and a per-run spend cap
(default $1.00)

2. Check prerequisites and report: the absolute path of claude (`command -v
claude`), `claude --version` (the --permission-prompts flag needs
v2.1.259 or later), and whether jq, flock and crontab are installed.
If something is missing, give me the install command and wait. Do not
run sudo yourself.

3. Show me, then write after I approve each one:
- watchlist.md with my URLs and reasons
- task.md: the unattended instructions (read watchlist.md and
state/seen.json, fetch each page once with WebFetch first and the
Apify rag-web-browser tool only as a fallback, write
digests/YYYY-MM-DD.md with new versions, "No change" and "Failed"
lines, overwrite state/seen.json, write no other file, never invent
versions)
- mcp.json, only if I use Apify: one http server named apify at
https://mcp.apify.com?tools=apify/rag-web-browser with the header
"Authorization": "Bearer ${APIFY_TOKEN}". No token in this file.
- run.sh: set -euo pipefail, cd to the job folder, load the env file,
call claude by absolute path with -p "$(cat task.md)",
--tools "Read,Write,Edit,WebFetch", --strict-mcp-config (plus
--mcp-config ./mcp.json if I use Apify), --permission-mode dontAsk,
--permission-prompts none, --allowedTools "WebFetch"
"Edit(digests/**)" "Edit(state/**)" (plus "mcp__apify__*" if I use
Apify), --max-turns 40, --max-budget-usd <my cap>,
--no-session-persistence, --output-format json > logs/<date>.json,
and on a non-zero exit print the result field from that log.
- create the folders digests/, state/ and logs/.

4. Secrets: create ~/.config/nightly-digest/env with chmod 600 and
PLACEHOLDER values only (CLAUDE_CODE_OAUTH_TOKEN= or
ANTHROPIC_API_KEY=, and APIFY_TOKEN= if used). Never ask me to paste a
token into this chat. Tell me to fill the file with nano myself and
say "done". Warn me not to set both Claude variables, because
ANTHROPIC_API_KEY wins whenever it is present.

5. Dry run: tell me it will cost up to the cap, then run run.sh once with
--max-budget-usd 0.50. Show me the digest file, the subtype, is_error,
num_turns and total_cost_usd from the log, and any permission_denials.
If anything failed, explain why and fix it with me.

6. Only after I approve: show the exact crontab line (absolute paths,
flock -n on a lock file, output appended to logs/cron.log), add it
with crontab, and show me `crontab -l`.

7. Finish with how to pause the job, where the logs are, and how to
change the watchlist.

Claude asks before each change it makes. Read each step before you approve it.

The rest of this post is the same setup done by hand, with the reasoning behind each flag.

What you end up with​

A folder on a server that cron runs once a night:

~/nightly-digest/
├── watchlist.md # pages to watch, and why each matters to you
├── task.md # the instructions Claude follows unattended
├── mcp.json # optional: Apify MCP server, token read from env
├── run.sh # the cron entry point
├── digests/ # one Markdown digest per night (Claude can write here)
├── state/seen.json # versions seen last time (Claude can write here)
└── logs/ # one JSON result per night: cost, turns, denials

Secrets live outside that folder, in ~/.config/nightly-digest/env.

The example job is a dependency and changelog digest. Each night Claude reads a short list of release pages, works out which versions are new since the last run, and writes a digest that flags only what touches your stack. It is deliberately not competitor monitoring: that job has its own guide on building an AI competitive-intelligence dashboard.

Headless Claude Code in five flags​

Everything here comes from the Claude Code headless guide and the CLI reference.

FlagWhat it does in a cron job
-p / --printRuns non-interactively, then exits. Exit code 0 on success, non-zero on failure.
--output-format jsonPrints one JSON object with result, is_error, num_turns, total_cost_usd, permission_denials and session_id.
--max-budget-usdStops the run once Claude Code's estimated API spend reaches the amount. Print mode only. Spend can pass the cap, so leave headroom.
--max-turnsCaps agentic turns. No limit by default. The run exits with an error when it hits the cap.
--permission-mode dontAskDenies every call that would otherwise prompt. Reads inside the working directory and anything on your allow list still run.

Two more make unattended runs predictable:

  • --permission-prompts none tells Claude that nobody can approve a request, so it doesn't retry a denied call. It needs Claude Code v2.1.259 or later.
  • --tools "Read,Write,Edit,WebFetch" removes every other built-in tool, Bash included, from Claude's context.

In our first test without --tools, Claude tried to write the digest with a Bash heredoc. dontAsk denied it, and Claude fell back to the Write tool. That cost an extra turn, and --tools stops it from happening.

Authenticate without a browser​

A server has no browser for /login, so pick one of these and put it in the env file. Claude Code's authentication docs list the order it checks credentials in.

OptionHowBillingWatch out for
Subscription tokenRun claude setup-token on your laptop, approve in the browser, then copy the printed token into CLAUDE_CODE_OAUTH_TOKENCounts against your Pro, Max, Team or Enterprise usageValid for one year. It can't fetch claude.ai connectors, but MCP servers you configure locally still work.
API keyCreate a key in the Claude Console and set ANTHROPIC_API_KEYPay per tokenIn -p mode the key is always used when present, ahead of an OAuth token. Don't set both.

Subscription limits are a real failure mode. Our first scheduled-style test landed while the account had hit its session limit. Claude Code exited with code 1, is_error: true, api_error_status: 429, and the result text "You've hit your session limit". Oddly, the subtype field still said success. So check the exit code or is_error, never subtype.

Run subscription jobs at hours when you are not using Claude interactively, or use an API key for jobs that must run.

Why this setup skips --bare

--bare is the documented recommendation for scripts, because it stops Claude Code from loading hooks, plugins, CLAUDE.md files and MCP servers it finds on the machine. But bare mode does not read CLAUDE_CODE_OAUTH_TOKEN, so it only works with an API key.

This setup gets most of the same isolation another way:

  • --strict-mcp-config loads only the MCP servers you name.
  • --tools limits the built-in tools.
  • The job folder contains no CLAUDE.md and no .claude/ directory.

If you use an API key, you can add --bare as well.

Step 1: a server that meets the requirements​

Claude Code's system requirements are 4 GB+ RAM, an x64 or ARM64 processor, and Ubuntu 20.04+, Debian 10+ or Alpine 3.19+. That rules out many entry-level 1 to 2 GB plans, which is why this post doesn't promise a "$10 VPS".

The job itself is light. Claude runs in Anthropic's cloud, and any scraping runs on Apify's servers. The VPS only needs to start the process and hold the files. For reference, the smallest Liquid Web self-managed VPS that meets the requirement is $17/month subject to change · verified 2026-10-21, with 2 vCPU, 4 GB RAM and 80 GB SSD. Any provider works; our VPS comparison for scraping workloads covers the trade-offs.

Install Claude Code with the native installer, which puts the binary at ~/.local/bin/claude and keeps it updated:

curl -fsSL https://claude.ai/install.sh | bash
claude --version
sudo apt install jq # flock ships with util-linux on Debian and Ubuntu

Step 2: the job folder​

watchlist.md: one URL per line, then why it matters to you. The "why" is what lets Claude decide what is worth flagging:

# Watchlist: one URL per line, then why it matters to me

- https://github.com/apify/crawlee/releases | our scrapers pin crawlee 3.x; flag anything about 4.0
- https://github.com/microsoft/playwright/releases | we run Playwright in CI; flag browser or Node version changes
- https://nodejs.org/en/blog/release | servers run Node 22 LTS; flag security releases

task.md: the unattended instructions. Write it as if for a colleague who can't ask you anything:

You are running unattended as a nightly job. Nobody can answer questions.

1. Read watchlist.md. Each line is a URL, then why it matters to me.
2. Read state/seen.json if it exists. It maps each URL to the list of
version names you saw last time.
3. For each URL, get the page. Try WebFetch first. If WebFetch fails,
returns an error page, or shows no release list, use the Apify tool
(rag-web-browser) with the URL as the query, maxResults 1, markdown output.
Never fetch more than one page per URL.
4. List the version names or release titles on the page. Compare them
with state/seen.json. New = present now, absent last time. On the first
run (no state file), treat only the newest entry as new.
5. Write digests/YYYY-MM-DD.md (today's date):
- one section per URL with new entries: version, one-line summary,
and "Action:" only if it touches the reason in watchlist.md
- a "No change" line listing URLs with nothing new
- a "Failed" line listing URLs you could not read, with the reason
6. Overwrite state/seen.json with the version names you saw tonight.
7. Do not write any other file. Do not invent versions; if unsure, say so.

Storing only version names in state/seen.json, not whole pages, keeps the state file small. It also keeps Claude from spending output tokens copying 40 KB pages back to disk.

Step 3: web access, free first, Apify as fallback​

Claude Code's built-in WebFetch has no per-page fee and was enough for all three pages in our test. Some pages aren't so easy: changelogs rendered by JavaScript, pages behind bot protection, or sites that rate-limit datacenter IPs. A VPS IP is exactly the kind of address those sites block.

For those, connect the hosted Apify MCP server with only one Actor exposed, RAG Web Browser. It loads a URL in a real browser and returns clean Markdown.

mcp.json:

{
"mcpServers": {
"apify": {
"type": "http",
"url": "https://mcp.apify.com?tools=apify/rag-web-browser",
"headers": { "Authorization": "Bearer ${APIFY_TOKEN}" }
}
}
}
  • A Bearer token instead of OAuth. The hosted server's OAuth sign-in needs a browser, so on a server you authenticate with Authorization: Bearer <token>, which Apify's MCP docs support.
  • ${APIFY_TOKEN} is expanded from the environment. Claude Code expands variables in the url and headers fields, so the token never sits in the file.
  • Keep "type": "http". Without it, Claude Code skips the entry, and the run still exits 0. The mcp_server_errors field in the stream-json init event is the only sign.
  • ?tools=apify/rag-web-browser exposes one Actor and nothing else. Claude can't start an arbitrary paid scraper from this job.

We tested the Actor on the Crawlee releases page. The run returned the full release list as 38,741 characters of Markdown and was billed $0.0037: one page fetch, plus eight "Actor start" events because the run used 8 GB of memory. Fetches are priced per page and start events per GB. On the Free plan a fetched page is $0.00228 subject to change · verified 2026-10-21, so budget about half a cent per page that falls back to Apify.

For more on keeping an MCP tool list short, see the Claude Code web research setup, which applies the same idea to interactive sessions.

Step 4: secrets and the run script​

Create the env file outside the job folder and lock it down:

mkdir -p ~/.config/nightly-digest
install -m 600 /dev/null ~/.config/nightly-digest/env
nano ~/.config/nightly-digest/env
# ~/.config/nightly-digest/env (chmod 600)
CLAUDE_CODE_OAUTH_TOKEN=paste-the-setup-token-output-here
# or, instead of the line above: ANTHROPIC_API_KEY=sk-ant-...
APIFY_TOKEN=apify_api_...

The file sits outside the working directory, so a dontAsk run can't read it: reads outside the working directories need approval, and dontAsk denies anything that needs approval. With --tools dropping Bash, Claude also has no shell from which to echo an environment variable.

run.sh:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
set -a; source "$HOME/.config/nightly-digest/env"; set +a
today=$(date +%F)
log="logs/$today.json"

if ! "$HOME/.local/bin/claude" -p "$(cat task.md)" \
--tools "Read,Write,Edit,WebFetch" \
--strict-mcp-config --mcp-config ./mcp.json \
--permission-mode dontAsk --permission-prompts none \
--allowedTools "WebFetch" "Edit(digests/**)" "Edit(state/**)" "mcp__apify__*" \
--max-turns 40 --max-budget-usd 1.00 \
--no-session-persistence \
--output-format json > "$log"; then
echo "$today FAILED: $(jq -r '.result' "$log")"
exit 1
fi

jq -r '"\(.subtype) is_error=\(.is_error) cost=$\(.total_cost_usd) turns=\(.num_turns) denied=\(.permission_denials|length)"' "$log"

A few details:

  • Edit(digests/**) covers new files too. Claude Code checks the Write tool against Edit(...) path rules; a Write(...) path rule is accepted but never consulted. In our test, a write to digests/test.md succeeded and a write to other.md in the same folder was denied.
  • mcp__apify__* allows every tool from the server named apify. Since the URL exposes only one Actor, that means one paid tool.
  • --no-session-persistence stops a transcript being saved for every night's run. Drop it if you want to --resume a run and ask Claude what it did.
  • No Apify? Delete mcp.json, the --mcp-config ./mcp.json argument and "mcp__apify__*", and keep --strict-mcp-config.

Run it once by hand: chmod +x run.sh && ./run.sh.

Step 5: what a real run produced​

We ran this job on 2026-10-21 with the three-URL watchlist above. This was a first run with no state file, the default model, and all three pages read by WebFetch, so Apify wasn't needed.

MetricValue
Exit code / is_error0 / false
Turns9
Wall time61 seconds
total_cost_usd (Claude Code's estimate)$0.25
Permission denials1 (the Bash attempt that --tools now prevents)

The digest it wrote, trimmed. This is real output from that run; release details are as the pages showed them that day:

# Release digest 2026-10-21

First run (no previous state). Only the newest entry per URL is reported as new.

## https://github.com/apify/crawlee/releases
- **v4.0.0-rc.1** (06 Oct, pre-release): Second 4.0 release candidate. ...
- Action: This is a 4.0 pre-release, not a stable one. Our 3.x pin is
unaffected, but read the v4 upgrading guide before 4.0.0 goes stable.

## https://github.com/microsoft/playwright/releases
- **v1.64.1** (14 Oct): Bug fixes ... No browser or Node version changes.

## https://nodejs.org/en/blog/release
- **Node.js 24.22.0 (LTS)** (Oct 14, 2026): Only the title was visible, so no summary.
Node.js 26.12.0 (Current) came out the same day and is listed second.
Which one counts as "newest" is uncertain.

Two things are worth noticing:

  • It flagged its own uncertainty. On the Node.js page it said which entry was "newest" was unclear instead of guessing, as task.md asked. Rule 7 is doing work.
  • Cost scales with turns, not with URLs. Most of the $0.25 went to the default model reasoning through nine turns. If a run like this costs more than the digest is worth to you, add --model sonnet to run.sh before trimming the watchlist. The modelUsage field in the log shows the split per model.

On a subscription that figure is an estimate of usage, not a bill. With an API key it approximates what you pay; Anthropic notes both are client-side estimates.

Step 6: schedule it with cron​

Open your crontab with crontab -e and add one line. This one runs at 06:30 server time:

30 6 * * * flock -n /tmp/nightly-digest.lock $HOME/nightly-digest/run.sh >> $HOME/nightly-digest/logs/cron.log 2>&1
  • flock -n skips a run if last night's is somehow still going.
  • Absolute paths everywhere. Cron's PATH is minimal, which is why run.sh calls $HOME/.local/bin/claude rather than claude.
  • Server time. Check it with timedatectl. Most VPS images default to UTC.

For crontab syntax, logging and systemd timers, see cron jobs for web scraping on Linux.

Spend caps, in layers​

No single cap covers everything, so stack them:

  1. --max-budget-usd per run, on Claude's side. It is checked against an estimate and can overshoot.
  2. --max-turns per run, as a backstop against a loop.
  3. One exposed Actor in mcp.json, so the only paid Apify call is a single page fetch.
  4. Apify account limits. The Limits tab under Billing in Apify Console sets usage caps. When usage exceeds them, Apify suspends platform services rather than running up charges.
  5. A weekly look at the logs. jq -s 'map(.total_cost_usd) | add' logs/*.json totals Claude's estimated spend across every run.

When a VPS is the wrong tool​

Self-hosting is not always the best answer.

  • Claude Cowork scheduled tasks run on Anthropic's side even when your computer is asleep. There is no server to maintain, and connectors such as Apify plug straight in. For a digest that ends up in a chat or a document, that's less work.
  • Claude Code cloud scheduled tasks (formerly routines, created with /schedule or at claude.ai/code/routines) run Claude Code itself on Anthropic's cloud on Pro, Max, Team and Enterprise plans, against a GitHub repository. They are a research preview. The minimum interval is one hour, and runs use no permission prompts, so every included connector's tools are available without approval.

A VPS earns its keep when you need things those can't give you:

  • files and state that live on the server
  • access to a private network or database
  • an exact allow list of tools and file paths
  • intervals shorter than an hour
  • billing through an API key instead of your subscription

If you already run a self-hosted MCP server on a VPS, the same box can host these jobs.

Frequently Asked Questions

Yes. Run claude setup-token, which prints a one-year OAuth token, and set it as CLAUDE_CODE_OAUTH_TOKEN. Anthropic documents it for CI pipelines and scripts. Runs count against your plan's usage limits, and a run that hits the limit exits with an error.

Claude Code stops the run once its estimated spend reaches the cap. The estimate is client-side and spend can pass the cap, so set it with some headroom and check is_error and the exit code in your script.

It approves everything, including shell commands that would read your secrets file. dontAsk with an explicit allow list does the opposite: anything you did not list is denied, and the JSON log records each denial.

No. WebFetch handled every page in our test. Apify's RAG Web Browser is a fallback for JavaScript-rendered or blocking pages, at about half a cent per page on the Free plan, and the job only calls it when WebFetch fails.

Anthropic lists 4 GB or more for Claude Code. The job itself is light because the model runs in Anthropic's cloud and any scraping runs on Apify's servers.

Start with one page​

Begin with one URL in the watchlist and a $0.50 cap, and read the first three nights of logs before you add more. When the digests look right, add pages one at a time. If a page keeps landing in the "Failed" line, that is the one to route through Apify's RAG Web Browser. For other jobs worth scheduling, Claude prompts that use live web data collects prompts by role.

Common mistakes and fixes

The cron job runs but nothing is written and the log shows exit code 1

Read the result field of that night's JSON log. A subscription usage limit shows up as 'You've hit your session limit' with is_error true. Move the job to a quieter hour or switch to an API key.

claude: command not found in cron

Cron runs with a minimal PATH. Call Claude Code by its absolute path, $HOME/.local/bin/claude for the native installer, as run.sh does.

The Apify tool never appears in the run

Check that APIFY_TOKEN is set in the env file and that mcp.json has "type": "http". Claude Code skips a url entry with no type and the run still exits 0.

Permission denials in the JSON log

Expected in dontAsk mode: anything not on the allow list is refused. Add a narrow rule only if the job genuinely needs that tool, for example Edit(reports/**).

Advertise on this page