Deploy config and serverless schedules
deployWrite the platform's own deploy files from main.yaml for Vercel, Cloudflare Workers, Fly.io and Docker, wake schedules through one tick route, check the build before it ships and diff two environments.
deployFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
deploy writes your platform's own deploy files from main.yaml, wakes schedules through one tick route, checks the build before it ships and diffs two environments.
At a glance
deploy:
target: vercel
regions: [fra1]
port: 3000
tick:
secret: secret:CRON_SECRET
late: 10m
vercel: { plan: pro }
docker: { base: node:24-slim }
jobs:
schedules:
nightly-report: "0 3 * * *"
seal:
mode: warn
exclude: ["**/*.map"]
publish: { sbom: false }// Hono on Vercel: the platform cron calls this route with the bearer secret
import { Hono } from "hono";
import { createTick } from "mainsdk/deploy";
import { app } from "mainsdk";
const tick = createTick(app());
const router = new Hono();
router.all("/_mainsdk/jobs/tick", (c) => tick.handle(c.req.raw));
// Cloudflare Workers: the same code, in process, for a cron trigger
export default { fetch: router.fetch, scheduled: (e) => tick.scheduled(e) };Calls
createTick(app)tick.handle(req)tick.scheduled(event)deploySection(config)staticSchedules(config)slotOf(expr, now)
Guarantees
Whatever the driver.
- MainSDK writes and checks deploy files; it never pushes a deploy, holds a platform token or hosts the app
- Generated blocks are owned and checked; every other key and line of the file is kept byte for byte
- Regions follow the data regions, and a region outside them is a finding
- A schedule runs once per slot, however many times the platform calls the tick
- The build fails before anything is served when a required env var name is missing
- deploy config, preflight and diff make no network call and read no platform credential
Drivers
4 drivers. Select one to see its line in main.yaml.
deploy:
driver: vercelChange this one line to swap providers; your calls stay the same.Vercel integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-DEPLOY-0001 | Checkmainsdk check, before deploy | A schedule can't be deployed: its expression is computed at runtime and the target has no long-running process, or jobs.schedules and a jobs.schedule call give one name two expressions |
MAIN-DEPLOY-0002 | Checkmainsdk check, before deploy | deploy.regions names a region outside the app's data regions, or the target can't run in a data region |
MAIN-DEPLOY-0003 | Checkmainsdk check, before deploy | deploy preflight found required env var names missing or empty in the build; the names are listed, never a value |
MAIN-DEPLOY-0004 | Checkmainsdk check, before deploy | A schedule or region breaks the declared platform plan's limit (for example a cron more often than daily on Vercel Hobby, or two regions on Hobby) |
MAIN-DEPLOY-0005 | Checkmainsdk check, before deploy | A Next.js app targets Docker or Fly without output: "standalone" in its Next.js config |
MAIN-DEPLOY-0010 | Checkmainsdk check, before deploy | A generated block is stale against main.yaml, or was edited by hand; the fix is the deploy config command that rewrites it |
MAIN-DEPLOY-0011 | Checkmainsdk check, before deploy | A secret would reach the image or the build log: a Dockerfile ARG or ENV named after a secret: reference, or .dockerignore not excluding .env* |
MAIN-DEPLOY-0012 | Checkmainsdk check, before deploy | deploy diff --fail-on widen found a change that widens what the target environment may do: a new allowed host, a new secret, a new region or a looser grant |
MAIN-DEPLOY-0006 | RuntimeError responses and logs | See the spec |
MAIN-DEPLOY-0007 | RuntimeError responses and logs | See the spec |
MAIN-DEPLOY-0008 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Deploy section and jobs.schedules in main.yaml with region and plan-limit rules | Phase 1 | 0.4 | deploy#contract |
| Schedules from jobs.schedules and literal jobs.schedule calls | Phase 1 | 0.4 | deploy#schedules |
| vercel.json regions | Phase 1 | 0.4 | deploy#vercel |
| wrangler.toml with cron triggers and the in-process scheduled handler | Phase 1 | 0.4 | deploy#cloudflare |
| fly.toml and a non-root Dockerfile with probes and a shutdown deadline | Phase 1 | 0.4 | deploy#fly-docker |
Quick start
deploy:
target: vercel
regions: [fra1]
port: 3000
tick:
secret: secret:CRON_SECRET
late: 10m
vercel: { plan: pro }
docker: { base: node:24-slim }
jobs:
schedules:
nightly-report: "0 3 * * *"// The platform cron calls this route with the bearer secret; Workers calls scheduled in process
import { createTick, type ScheduledEvent } from "mainsdk/deploy";
import { app } from "mainsdk";
const tick = createTick(app());
export default {
fetch: (req: Request) => {
const { pathname } = new URL(req.url);
return tick.match(pathname) ? tick.handle(req) : new Response("Not found", { status: 404 });
},
scheduled: (e: ScheduledEvent) => tick.scheduled(e),
};Configuration
The Quick start shows the whole deploy section.
| Key | Type | What it does |
|---|---|---|
deploy.target | string | vercel (default), cloudflare, fly, docker or node |
deploy.regions | list | Function or machine regions; they must follow the data regions |
deploy.port | number | The port the server listens on |
deploy.tick.secret | secret reference | The bearer secret the platform sends to the tick route |
deploy.tick.late | duration | A tick later than this after its slot still runs and logs MAIN-DEPLOY-0008 |
deploy.vercel.plan | string | hobby or pro; hobby limits cron and regions (MAIN-DEPLOY-0004) |
deploy.docker.base | string | The base image of the generated Dockerfile |
jobs.schedules | map | Schedule name to cron expression; each becomes a platform cron entry |
seal | map | The build seal the deploy ships with: mode (off, warn or refuse), exclude globs and publish.sbom. Its checks are in Seal your build |
What you get
Write the platform's own deploy files from main.yaml for Vercel, Cloudflare Workers, Fly.io and Docker, wake schedules through one tick route, check the build before it ships and diff two environments.
Calls
| Call | What it does |
|---|---|
createTick(app) | Builds the tick route and the Workers scheduled handler for an app. Mount tick.match(path) before your own routes. |
tick.handle(req) | Answers POST /_mainsdk/jobs/tick?schedule=<name>: 200 { schedule, slot, ran }, or a problem 401, 404 or 405. Never throws. |
tick.scheduled(event) | Runs every schedule whose expression equals event.cron in process, with no HTTP call, for a Cloudflare cron trigger. |
deploySection(config) | Reads the deploy section of a config with its defaults applied. |
staticSchedules(config) | The jobs.schedules of a config as name to cron expression. |
slotOf(expr, now) | The latest minute at or before now (UTC) that a cron expression matches, as epoch milliseconds, or null. |
What mainsdk deploy config writes
| Target | Files it owns | What is generated |
|---|---|---|
vercel | vercel.json | The keys regions, crons (one per schedule, calling the tick route) and buildCommand (mainsdk deploy preflight --env <env> && <your build>) |
cloudflare | wrangler.toml | A block with compatibility_flags = ["nodejs_compat"], triggers.crons (one per distinct expression), a name, a main entry and a compatibility date |
fly | fly.toml, Dockerfile, .dockerignore | primary_region, kill_timeout (shutdown deadline plus 5 s), an http_service with a check on health.ready, and no cron |
docker | Dockerfile, .dockerignore | A multi-stage image whose runtime stage runs as node, with STOPSIGNAL SIGTERM and a node -e health check on health.live |
node | nothing | Only deploy/env.<env>.example |
Every target also writes deploy/env.<env>.example, which lists each required env var name with where it is used and no value, and mainsdk.deploy.json, which records each file and a hash of the part MainSDK owns. Commit both.
MainSDK owns the lines between # mainsdk:generated:begin and # mainsdk:generated:end and, in vercel.json, the three keys above. Everything else in the file is yours and is kept byte for byte. A key that you define outside the block wins and the block leaves it out. mainsdk check reports a stale or hand-edited block as MAIN-DEPLOY-0010 with the command that rewrites it.
Regions and plan limits
The function or machine region follows the data regions of db.regions and files.regions.
| Data region | Vercel | Fly.io | Cloudflare Workers |
|---|---|---|---|
us | iad1 | iad | global; info finding only |
eu | fra1 | fra | global; info finding only |
in | bom1 | bom | global; info finding only |
A deploy.regions entry outside the data regions is MAIN-DEPLOY-0002. With deploy.vercel.plan: hobby, a cron more often than once a day, more than two cron entries or more than one region is MAIN-DEPLOY-0004.
Schedules on serverless targets
On vercel and cloudflare there is no process to keep a timer, so the in-process scheduler of jobs.schedule is off and each schedule becomes a platform cron entry. The platform calls POST /_mainsdk/jobs/tick?schedule=<name> with Authorization: Bearer <deploy.tick.secret>. The tick answers as soon as the job is queued and runs each schedule once per slot, so a duplicate call answers ran: false. A tick later than deploy.tick.late after its slot still runs and logs MAIN-DEPLOY-0008. The tick is left out of the SLO request counts.
Schedules are the jobs.schedules of main.yaml plus every jobs.schedule("name", "expr", …) call whose name and expression are string literals. A computed one can't be deployed to a serverless target: MAIN-DEPLOY-0001, with its file and line.
Preflight and environment diff
mainsdk deploy preflight --env prod runs inside the platform build, where the secrets are. It checks that each required env var name is present and non-empty, the region and plan rules, and mainsdk check --env prod, and fails the build before anything is served. It prints names, never values.
mainsdk deploy diff --from staging --to prod lists what changes between two environments. With --fail-on widen it fails on a new allowed host, a new secret, a new region or a looser grant. MainSDK never pushes a deploy, holds a platform token or hosts your app; the platform's Git integration or CLI does that.
What it always does
- MainSDK writes and checks deploy files; it never pushes a deploy, holds a platform token or hosts the app
- Generated blocks are owned and checked; every other key and line of the file is kept byte for byte
- Regions follow the data regions, and a region outside them is a finding
- A schedule runs once per slot, however many times the platform calls the tick
- The build fails before anything is served when a required env var name is missing
- deploy config, preflight and diff make no network call and read no platform credential
Errors and checks
| Code | When | What to do |
|---|---|---|
MAIN-DEPLOY-0001 | A schedule cannot be deployed: its expression is computed at runtime, or one name has two expressions | Use a string literal expression |
MAIN-DEPLOY-0002 | deploy.regions is outside the data regions, or the target cannot run in one | Pick a region from the table above |
MAIN-DEPLOY-0003 | deploy preflight found required env var names missing or empty | Set the names it lists on the platform |
MAIN-DEPLOY-0004 | A schedule or region breaks the declared plan's limit | Lower the cron rate or regions, or change the plan |
MAIN-DEPLOY-0005 | A Next.js app targets Docker or Fly without output: "standalone" | Set it in the Next.js config |
MAIN-DEPLOY-0006 | A tick arrived without the right bearer secret (401) | Send deploy.tick.secret |
MAIN-DEPLOY-0007 | A tick named a schedule the app does not declare (404) | Declare it under jobs.schedules |
MAIN-DEPLOY-0008 | A tick ran later than deploy.tick.late after its slot | Check the platform's cron delay |
MAIN-DEPLOY-0010 | A generated block is stale or hand-edited | Run the deploy config command the finding names |
MAIN-DEPLOY-0011 | A secret would reach the image or the build log | Remove the ARG or ENV; exclude .env* in .dockerignore |
MAIN-DEPLOY-0012 | deploy diff --fail-on widen found a widening change | Review the new host, secret, region or grant |
Each code's cause and fix is in check codes.
Swapping drivers
deploy:
target: fly # was vercelThen run mainsdk deploy config again. Your app code stays the same; the files it owns change to the new platform's. The drivers are:
| Driver | Provider | |
|---|---|---|
vercel | Vercel | default |
cloudflare | Cloudflare Workers | |
fly | Fly.io | |
docker | Docker |
Testing
The tick, the file writers and the preflight run against fake platforms, with no network call (packages/sdk/test/deploy-tick.test.ts, packages/cli/test/deploy.test.ts). Check your own files in CI with mainsdk deploy config --check and mainsdk deploy preflight --env <env>.
Next
Was this page helpful?