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.
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:
- 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>" } }), orset_env_varson the preview followed bydeploy. - If the preview inherited site protection, add
/_system/ticktoprotection.publicPaths(see above) — otherwise the ingress gate answers the request before your app does. - 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.jsonnameto the preview's name gets pastconfig_name_mismatch— and then registers a schedule whose tick URL is still built fromdomains.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
- 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.- If the app is protected, add
/_system/ticktoprotection.publicPathsso the tick isn't answered by the gate. - Verify in
reconcile_project'sapplied.schedulesthat your jobs registered. - 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.