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:
- The platform's central scheduler tracks every app's declared cron.
- When a cadence is due, the platform POSTs your app's
/_system/tickwith a system bearer token (DM_SCHEDULE_TICK_SECRET) and a JSON body naming the job. The call wakes a sleeping instance. - Your app verifies the token and dispatches to your handler.
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.
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.
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
- Confirm the work is genuinely side-effecting (otherwise derive it on read).
- Add the
schedulesblock to.deploymill/project.json. - Implement
/_system/tick(verify the token, dispatch onjob, make handlers idempotent). reconcile_project, thendeployso the injectedDM_SCHEDULE_TICK_SECRETand your receiver go live.- Verify in
reconcile_project'sapplied.schedulesthat your jobs registered.
To remove a schedule, drop it from the array and reconcile_project. The platform unregisters it.