---
title: "Deploy config and serverless schedules"
description: "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."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "deploy"
canonical: "https://mainsdk.dev/docs/interfaces/deploy"
updated: "2026-10-28"
---

# Deploy config and serverless schedules

> 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.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/deploy · Updated: 2026-10-28

`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

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/deploy.md
- Category: Reliability and operations

Calls:

- `createTick(app)`
- `tick.handle(req)`
- `tick.scheduled(event)`
- `deploySection(config)`
- `staticSchedules(config)`
- `slotOf(expr, now)`

In main.yaml:

```yaml
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 }
```

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

Pick one in main.yaml with `deploy.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Vercel | `vercel` | yes |  |
| Cloudflare Workers | `cloudflare` |  |  |
| Fly.io | `fly` |  |  |
| Docker | `docker` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-DEPLOY-0001` | check | 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` | check | `deploy.regions` names a region outside the app's data regions, or the target can't run in a data region |
| `MAIN-DEPLOY-0003` | check | `deploy preflight` found required env var names missing or empty in the build; the names are listed, never a value |
| `MAIN-DEPLOY-0004` | check | 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` | check | A Next.js app targets Docker or Fly without `output: "standalone"` in its Next.js config |
| `MAIN-DEPLOY-0010` | check | 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` | check | 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` | check | `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` | runtime |  |
| `MAIN-DEPLOY-0007` | runtime |  |
| `MAIN-DEPLOY-0008` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Deploy section and jobs.schedules in main.yaml with region and plan-limit rules | 1 | 0.4 | `deploy#contract` |
| Schedules from jobs.schedules and literal jobs.schedule calls | 1 | 0.4 | `deploy#schedules` |
| vercel.json regions | 1 | 0.4 | `deploy#vercel` |
| wrangler.toml with cron triggers and the in-process scheduled handler | 1 | 0.4 | `deploy#cloudflare` |
| fly.toml and a non-root Dockerfile with probes and a shutdown deadline | 1 | 0.4 | `deploy#fly-docker` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
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 * * *"
```

```ts title="app/example.ts"
// 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](https://mainsdk.dev/docs/guides/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](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="main.yaml"
deploy:
  target: fly   # was vercel
```

Then 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

- [Seal your build](https://mainsdk.dev/docs/guides/seal-your-build)
- [Jobs and schedules](https://mainsdk.dev/docs/interfaces/jobs)
- [Check codes](https://mainsdk.dev/docs/reference/check-codes)
