Browse docs All docs
Docs / Scheduled jobs (cron) reference
Guidedeploymill://guides/schedules

Scheduled jobs (cron)

Run time-triggered work (a daily digest email, a nightly webhook, an hourly sync) on a schedule. This guide covers when you actually need a scheduler (often you don't), how to declare one, and the /_system/tick receiver your app implements.

First: is the work lazy or side-effecting?

Most "scheduling" isn't. Before declaring a schedule, decide which half you have:

  • Lazy (compute-on-read). "Auto-archive after 30 days", "mark overdue", "hide done items after a week", "reset a streak". These only matter when someone looks, so don't schedule anything. Derive them at query time from a timestamp (archived = now - lastActivity > 30d). Zero cron, idempotent by construction, and it survives scale-to-zero perfectly. If no one opens the app, nothing needed to happen.
  • Side-effecting. "Email me a daily digest", "send a reminder push", "POST a webhook nightly". There's no reader to trigger these. The effect must happen in the world. This is the only case that needs a schedule.

If you can make it lazy, do that and stop here.

Declare the schedule (the contract)

Add a schedules array to .deploymill/project.json. Each entry is a name (a lowercase slug) and a standard 5-field cron expression (UTC):

{
  "version": 2,
  "name": "myapp",
  "domains": { "prod": "myapp.example.com" },
  "rollback": "auto",
  "schedules": [
    { "name": "daily-digest", "cron": "0 8 * * *" },
    { "name": "hourly-sync",  "cron": "0 * * * *" }
  ]
}

Cron is minute hour day-of-month month day-of-week and supports *, values, a-b ranges, a,b lists, and */n steps. Names must be unique within the app. Then reconcile_project. It registers the schedules with the platform scheduler and, the first time the app declares any schedule, mints a per-app secret and injects it into the app env as DM_SCHEDULE_TICK_SECRET (takes effect on the next deploy, like any other env/secret change).

Schedules require a web app with domains.prod. The platform fires them by calling your app over HTTPS. A worker (no domain) can't receive ticks.

How firing works

You don't run a timer in your app (a single-serve instance sleeps, and a scaled-out one would double-fire). Instead:

  1. The platform's central scheduler tracks every app's declared cron.
  2. When a cadence is due, the platform POSTs your app's /_system/tick with a system bearer token (DM_SCHEDULE_TICK_SECRET) and a JSON body naming the job. The call wakes a sleeping instance.
  3. Your app verifies the token and dispatches to your handler.

Protected apps: open /_system/tick. The tick carries only the Authorization: Bearer <DM_SCHEDULE_TICK_SECRET> header — no site-protection bypass — so on an app with protection.enabled: true the ingress gate answers the tick before your app ever sees it (a 401 under mode: "basic", a login redirect under mode: "organization"), and every run is recorded as a failure. Add /_system/tick to protection.publicPaths in .deploymill/project.json (or via set_app_protection) and reconcile. That exempts only that path from the gate; the tick's Bearer secret is still its authentication, which is why your receiver must verify DM_SCHEDULE_TICK_SECRET in constant time before doing any work.

Idempotency: derive, don't fire. Cron is at-least-once, so write each handler to do the work for whoever is due, not "do the one thing". A double-fire then recomputes "who's due" and finds nothing. The platform also holds a run-lock keyed on (app, job, scheduled-time) so a given tick claims once, but your handler being idempotent is the real guarantee. The lock is belt-and-suspenders.

Previews never run your schedules

A preview app receives no ticks — ever. Schedules are registered when a project reconciles, against that app's id and its domains.prod host. A preview is a separate app with its own id: create_preview doesn't reconcile, and reconcile_project refuses a preview outright (config_name_mismatch, because the preview's name is <parent>-<branch-slug> and your config's name is the parent's). So nothing ever registers your cron for it. The platform clock is running fine; there is simply no row for that app.

This is easy to misread, because the preview looks schedule-ready: it inherits the parent's env, DM_SCHEDULE_TICK_SECRET included, so its /_system/tick receiver is deployed and would happily accept a tick. It just never gets one. create_preview says so in warnings when the parent declares schedules, and get_app on the preview returns a schedulesNote explaining its empty schedules array.

Testing a job on a preview: tick it by hand

The receiver works, so drive it yourself:

  1. Give the preview a tick secret you know. The inherited one is never readable back (env values are write-only through every tool), so set your own — create_preview({ ..., envOverrides: { DM_SCHEDULE_TICK_SECRET: "<a test value>" } }), or set_env_vars on the preview followed by deploy.
  2. If the preview inherited site protection, add /_system/tick to protection.publicPaths (see above) — otherwise the ingress gate answers the request before your app does.
  3. POST the tick exactly as the platform would:
curl -X POST https://<preview-host>/_system/tick \
  -H "Authorization: Bearer <a test value>" \
  -H "content-type: application/json" \
  -d '{"job":"daily-digest","scheduledTime":"2026-08-25T08:00:00Z"}'

That exercises the real handler against real preview data — the thing worth testing. What it does not exercise is the cadence itself (that your cron expression means what you think); that only runs on prod.

Don't rename the config to force a reconcile. Editing the branch's project.json name to the preview's name gets past config_name_mismatch — and then registers a schedule whose tick URL is still built from domains.prod, so your preview's cron POSTs production, on top of prod's own schedule. Tick by hand instead.

See what's scheduled and when it fired

From an agent, get_app. Call get_app and read its schedules array: each entry is { name, cron, nextRun, lastRun, lastSuccess, recentRuns }. lastRun (and each recentRuns entry) carries { status, scheduledFor, finishedAt, httpStatus, error }, where status is the discriminated fired | failed | running | unknown union, so you can branch on whether your cron actually ran without opening the dashboard. lastRun: null means the schedule has never fired yet. lastSuccess is the most recent fired run (null if it's never succeeded), which tells you how far behind a stuck schedule is. The array is empty when the app declares no schedules. This is the same data the dashboard renders, so an agent can self-diagnose "did my schedule fire / why" entirely through the MCP surface.

From the dashboard. Open your app and pick the Schedules tab. It lists every schedule declared in .deploymill/project.json with its cadence, when it's next due, and for each run when it last fired, whether it succeeded (HTTP status, and the error if it failed), and when it last succeeded, plus an expandable recent-run history. A run that never recorded an outcome (the dispatcher restarted mid-run) shows as unknown with the reason inline. Each run has a View logs link that jumps to the Logs tab windowed to that run's time and filtered to its job name, so you can read the handler output that explains what happened (best-effort: runtime logs are the live container's buffer, so an old run's output may have rotated away). If a schedule shows "never fired" even though it's in your project.json, it isn't registered yet: schedules are registered when the project reconciles, and only for an app that has a production domain (the platform POSTs /_system/tick over HTTPS). Run reconcile_project after attaching one. On a preview, “never fired” is permanent — a preview is never registered at all, and reconciling it is refused; see Previews never run your schedules.

Implement the /_system/tick receiver

Your app exposes one endpoint that (a) checks the bearer token against DM_SCHEDULE_TICK_SECRET in constant time, then (b) dispatches on the job name. The secret is injected by the platform and never reaches an agent. Read it only from the env.

Node (Hono)

import { timingSafeEqual } from "node:crypto";

const TICK_SECRET = process.env.DM_SCHEDULE_TICK_SECRET ?? "";

function tokenOk(header: string | undefined): boolean {
  if (!header?.startsWith("Bearer ")) return false;
  const provided = Buffer.from(header.slice(7));
  const expected = Buffer.from(TICK_SECRET);
  return provided.length === expected.length && timingSafeEqual(provided, expected);
}

const handlers: Record<string, () => Promise<void>> = {
  "daily-digest": async () => {
    // Derive, don't fire: send to everyone DUE right now.
    const due = await findUsersDueForDigest();
    for (const u of due) await sendDigest(u);
  },
};

app.post("/_system/tick", async (c) => {
  if (!tokenOk(c.req.header("authorization"))) return c.json({ error: "unauthorized" }, 401);
  const { job } = await c.req.json<{ job: string; scheduledTime: string }>();
  const handler = handlers[job];
  if (!handler) return c.json({ error: "unknown_job", job }, 404);
  await handler();
  return c.json({ ok: true, job });
});

Python (FastAPI)

import hmac, os
from fastapi import APIRouter, Request, HTTPException

TICK_SECRET = os.environ.get("DM_SCHEDULE_TICK_SECRET", "")
router = APIRouter()

def token_ok(header: str | None) -> bool:
    if not header or not header.startswith("Bearer "):
        return False
    return hmac.compare_digest(header[7:], TICK_SECRET)

async def daily_digest() -> None:
    for user in await find_users_due_for_digest():  # derive, don't fire
        await send_digest(user)

HANDLERS = {"daily-digest": daily_digest}

@router.post("/_system/tick")
async def tick(request: Request):
    if not token_ok(request.headers.get("authorization")):
        raise HTTPException(status_code=401, detail="unauthorized")
    body = await request.json()
    job = body.get("job")
    handler = HANDLERS.get(job)
    if handler is None:
        raise HTTPException(status_code=404, detail="unknown_job")
    await handler()
    return {"ok": True, "job": job}

Return 2xx when the work is accepted. A non-2xx is recorded as a tick failure. Keep the handler fast (the platform allows ~30s per tick). For long work, enqueue and return.

Checklist

  1. Confirm the work is genuinely side-effecting (otherwise derive it on read).
  2. Add the schedules block to .deploymill/project.json.
  3. Implement /_system/tick (verify the token, dispatch on job, make handlers idempotent).
  4. reconcile_project, then deploy so the injected DM_SCHEDULE_TICK_SECRET and your receiver go live.
  5. If the app is protected, add /_system/tick to protection.publicPaths so the tick isn't answered by the gate.
  6. Verify in reconcile_project's applied.schedules that your jobs registered.
  7. Testing on a preview? It gets no ticks — tick it by hand.

To remove a schedule, drop it from the array and reconcile_project. The platform unregisters it.