DEPLOYTEMPLATE

Scheduled Jobs Spec

A cron job is code that runs when nobody is watching, which means it fails when nobody is watching. That is the entire discipline in one sentence. An interactive app that breaks gets a bug report within the hour. A nightly job that breaks gets discovered in six weeks, when someone asks why the digest emails stopped, and by then you're not fixing a bug, you're reconstructing six weeks of missed work.

On Vercel, a cron job is just an HTTP route that Vercel promises to call on a schedule, and that shape has two sharp edges. First, the route is a public URL: anyone who finds it can trigger your job, so it authenticates or it doesn't ship. Second, schedulers deliver "at least once," not "exactly once." Retries, overlapping runs, and manual triggers all mean your job will eventually run twice for the same period, and it has to be idempotent: running it twice produces the same result as once. A job that sends invoices needs this property before it sends its first invoice, not after its first double-bill.

The spec below covers the Vercel path, the contract every job handler must meet regardless of platform, and the honest line for when serverless cron stops fitting: jobs longer than your platform's timeout, or schedules tighter than a minute, belong on a worker. The dead-man's switch is not optional decoration. "The job stopped running" and "the job ran and failed" are different failures, and only the switch catches the first kind.

Prerequisites

  • A deployed Vercel project (see the Vercel deployment spec) or a host that can run a long-lived process.
  • A free healthchecks.io account (or equivalent) for the dead-man's switch.
markdown
# Scheduled jobs spec: [project name]

Implement the jobs listed below. Every job meets the handler contract. No
job ships without its dead-man's switch registered.

## The jobs

| Job | Schedule (UTC) | What it does | Max runtime | If it runs twice? |
|---|---|---|---|---|
| [daily-digest] | [0 13 * * *] | [assemble + send digest emails] | [60s] | [must not re-send: dedupe on (user, date)] |
| [cleanup] | [0 4 * * 0] | [delete expired rows, orphaned files] | [120s] | [harmless: deletes are naturally idempotent] |

Vercel cron schedules are UTC. Write "13:00 UTC" in this table, not "8am,"
or the job moves an hour twice a year and you spend a morning confused.

## Vercel wiring

`vercel.json` at the repo root:

```json
{
  "crons": [
    { "path": "/api/cron/daily-digest", "schedule": "0 13 * * *" },
    { "path": "/api/cron/cleanup", "schedule": "0 4 * * 0" }
  ]
}
```

## Handler contract (every job route, no exceptions)

1. AUTH FIRST. Vercel sends `Authorization: Bearer ${CRON_SECRET}`.
   Compare against the env var; wrong or missing means 401 and stop.
   Generate the secret (32+ random chars), set it in Vercel env vars.
   The route is a public URL. This header is the only thing making it not one.
2. IDEMPOTENT. Compute what "this period's work" means (e.g. digest for
   2026-08-10), check whether it is already done, skip if so. Record
   completion in the same transaction as the work where possible.
3. TIME-BOXED. Stay under [max duration for your plan]. If the work can
   exceed it, the job's only task is to enqueue smaller units, not do them.
4. LOGGED. One line at start (job name, period), one at end (items
   processed, duration, failures). No output means undebuggable.
5. PING ON SUCCESS. Last line of a successful run: HTTP GET to the job's
   healthchecks.io URL. See below.

## Dead-man's switch

For each job, create a healthchecks.io check with the job's schedule and a
grace period of [2x expected runtime]. The job pings on success; if no ping
arrives on schedule, YOU get an email. This catches the failure mode
monitoring misses: the job that never started. A cron entry deleted in a
refactor produces no error log anywhere. The missing ping is the only evidence.

## When Vercel cron is the wrong tool

Move the job to a worker (Railway, Fly.io, a $5 VPS running system cron or
a queue consumer) when any of these are true:

- Runtime exceeds the platform timeout even after splitting
- Schedule is tighter than once a minute
- The job needs state between runs beyond what the database holds
- You need guaranteed execution order across jobs

The handler contract above transfers unchanged. Auth, idempotency, logging,
and the ping are properties of the job, not the platform.

## Verification

- [ ] Calling the route without the bearer token returns 401
- [ ] Triggering the job twice for the same period does the work once
- [ ] Job appears in Vercel's cron dashboard and has run on schedule
- [ ] Stop pinging the healthcheck once (comment it out, deploy, wait):
      confirm the alert email actually arrives. An untested alarm is decor.

Adaptation notes:

  • GitHub Actions schedule: workflows are a fine free scheduler for jobs that don't need your app's runtime: hit your authenticated route with curl from the workflow. Beware that Actions cron drifts by minutes under load; fine for a digest, wrong for anything time-sensitive.
  • For "process a big backlog" jobs, invert the design: the cron enqueues work items, and a separate consumer processes them with retries per item. One poison item then fails alone instead of killing the whole run.
  • Jobs that send email or spend money get a kill switch: an env var like DIGEST_ENABLED=true checked at the top. Feature-flagging a cron job beats redeploying at 2am to stop it.
  • The mistake: testing the happy path and never the double-run. Trigger the job manually right after a scheduled run in staging and look at what happened. If two digests went out, the idempotency check is decorative.
  • Cost note (§4.6): a cron job calling an LLM API on a schedule is a subscription you signed up for without reading the price. Multiply cost-per-run by runs-per-month before you set the schedule, not after the invoice.