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 need no manual carve-out. Deploy Mill automatically routes /_system/tick around site protection at ingress in both basic and organization modes. The endpoint is not unauthenticated: your app still receives and must verify Authorization: Bearer <DM_SCHEDULE_TICK_SECRET> in constant time before doing any work. Only the site gate is bypassed so it cannot consume the request before the app checks its dedicated schedule credential.

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. POST the tick. /_system/tick automatically bypasses inherited site protection at ingress; the bearer secret below remains required.
  3. Send the same payload the platform sends:
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 | paused | running | unknown union. A paused run means the app was stopped or otherwise not runnable: DeployMill retained the schedule but did not send /_system/tick; it resumes delivery once the app is runnable again. lastRun: null means the schedule has never fired yet. lastSuccess is the most recent fired run, independent of later failed or paused attempts.

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. Paused runs show their reason inline; a run that never recorded an outcome (the dispatcher restarted mid-run) shows as unknown with its reason. Each run has a View logs link that jumps to the runtime logs windowed to that scheduled time — useful for handler output, though old log buffers may have rotated away.

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. No site-protection carve-out is needed: /_system/tick automatically bypasses the ingress gate and remains protected by DM_SCHEDULE_TICK_SECRET.
  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.