---
title: "Scheduled tasks"
description: "Run code on a cron schedule: a scheduled() handler for static, hybrid and Worker SSR apps, and the Laravel scheduler or a command (artisan, rake or your own Node route) for container apps."
---

Scheduled tasks run your code on a cron schedule without a request coming in. How you write them depends on the app's delivery mode: apps that run on the edge network call a `scheduled()` handler in your Worker code, and container apps run the Laravel scheduler or a command you name.

## Schedules and where they come from

Schedules come from two places, merged on each deploy:

- **Overview** → **Add resource** → **Scheduled task**. Add as many as you need (up to the limit below); rows you add there are stored on the app.
- A `crons` list in `dply.yaml`, committed with your code. These show on Overview as read-only, marked **Repo**.

```yaml
crons:
  - schedule: "*/5 * * * *"
  - schedule: "0 3 * * *"
    handler: "reports:send --daily"
```

A schedule is a standard 5-field cron expression in **UTC**. In the form, pick **Minutes**, **Hourly**, **Daily**, **Weekly** or **Monthly** and a time to build one, or choose **Custom** and type any expression. Changes take effect on the next deploy. Once an app has a task, a **Scheduled tasks** box appears on the Overview map under the app. Select it to see every schedule, **Edit** or **Run now** one, or **Add** another. Scheduled tasks are available once the app has a Worker to schedule: a container app, a Worker SSR app, or a static or hybrid app with [edge middleware](/docs/edge-middleware). Static apps with middleware have no **Add resource** button, so they get an **Add a scheduled task** link under the app box instead.

### How many you can have

- **Container apps: up to 50 scheduled tasks.** Every task shares one trigger that fires each minute. The app's Worker checks which tasks are due and wakes the app only for those, so a task that runs at 06:00 wakes the app at 06:00 and not before. The Laravel scheduler doesn't take a slot.
- **Worker SSR, hybrid, and static apps with edge middleware: up to 5 distinct schedules.** Each schedule is its own Cloudflare Cron Trigger, and Cloudflare allows 5 per Worker. Your `scheduled()` handler tells them apart by `controller.cron`. The sheet shows **N of 5 schedules used**. Schedules past the fifth are dropped at deploy and marked **Won't run**. Several commands can share one schedule.

> [!NOTE]
> On container apps a schedule can use numbers, `*`, ranges (`1-5`), lists (`1,15`), steps (`*/10`), and month and weekday names (`JAN`, `MON`). Cloudflare's extras `L`, `W`, `#` and `?` aren't supported: the form refuses them, and one in `dply.yaml` is marked **Won't run**.

[Preview deployments](/docs/preview-deployments) never run scheduled tasks; only production does.

## Static and hybrid apps

A static or hybrid app runs schedules through its edge middleware. Export a `scheduled` handler from `src/middleware.ts` (or `middleware.ts` at the repository root):

```ts
// src/middleware.ts
export default {
  async fetch(request, env) {
    // Let the request continue to your static files or origin.
    return new Response(null, {
      status: 204,
      headers: { "X-Dply-Middleware": "continue" },
    });
  },

  async scheduled(controller, env, ctx) {
    // controller.cron is the expression that fired
    console.log("cron", controller.cron);
  },
};
```

Every schedule calls the same `scheduled` export, so there is no command field for these apps; branch on `controller.cron`. Your environment variables are available on `env`. Without middleware there is nothing to schedule.

## Worker SSR apps

Each schedule calls the `scheduled` export of your app's Worker entry. Next.js (OpenNext), Astro, SvelteKit and Remix builds do not export one by default, so a schedule does nothing until your Worker entry exports `scheduled` alongside `fetch`.

## Container apps

### The Laravel scheduler

To run `schedule:run` every minute, open **Overview**, select **Add resource** → **Scheduled task**, choose **Run Laravel’s scheduler** at the top, and redeploy. You can also turn on **Run the Laravel scheduler every minute** in **Sleep, scaling, region**. dply adds the `dply/laravel` package to the image if your app does not require it (add it yourself if you ship your own `Dockerfile`).

Where the scheduler runs depends on whether the app has [queue workers](/docs/queue-workers):

| App has | The scheduler runs | Effect on sleep |
|---|---|---|
| At least one always-on queue worker | `php artisan schedule:work` inside `worker-0` | The web instances can sleep. Pausing the workers pauses the scheduler. |
| No always-on worker | A trigger every minute calls `schedule:run` in the app | The app is woken only when one of your tasks is due. |

After each run, the app reports its tasks' cron expressions and time zones, and dply wakes the app only in minutes when one of them is due. An app with only a nightly task sleeps the rest of the day. dply wakes the app every minute when it cannot tell: right after a deploy, when the report is more than a day old, or when the app has sub-minute tasks.

Your tasks use the time zones set in your schedule (`->timezone()` or `schedule_timezone`), not UTC.

Select the **Scheduler** card on **Overview** (or the **Queue workers** card when the app has workers) to see which mode is active. **Run now** runs `schedule:run` once in the live app and shows its output. **Remove** turns the scheduler off on the next deploy.

### Commands on a schedule

For anything other than the Laravel scheduler, add a **Scheduled task** on Overview (or in `dply.yaml`) with a command. Each run POSTs the command to `/_dply/schedule` in the live app.

On a Laravel or Rails container app, choose **Pick from the app's artisan commands** (or **rake tasks**) under **Command** to list what the live app defines. Your app's own commands are listed first; typing in the field suggests the rest. Asking wakes the app if it is asleep.

How the command runs:

- Laravel: runs through `dply/laravel`. An artisan command such as `reports:send --daily` runs through Artisan (a leading `php artisan` is optional). Anything else runs as a shell command from the app root, for example `php scripts/cleanup.php` or `node bin/sync.js`. A blank handler runs `schedule:run`.
- Rails: runs through `dply-rails`. A rake task such as `reports:daily` runs through Rake. Anything else runs as a shell command from the app root, for example `bin/rails runner Cleanup.call`.
- Node: your app handles `POST /_dply/schedule` itself. The body is JSON `{cron, handler}`; check that the `x-dply-queue-token` header equals `DPLY_QUEUE_TOKEN` from the app's environment, and answer JSON `{output}`:

```js
app.post("/_dply/schedule", express.json(), async (req, res) => {
  if (req.get("x-dply-queue-token") !== process.env.DPLY_QUEUE_TOKEN) {
    return res.status(403).json({ error: "Forbidden" });
  }
  const { handler, cron } = req.body; // e.g. "reports:daily", "0 6 * * *"
  // run the task for `handler` here
  res.json({ output: `ran ${handler}` });
});
```

**Run now** next to a listed command runs it once in the live app and shows the output, for Laravel, Rails and Node apps alike. Only commands already on the list can run, and you need edit rights. Rails tasks print no output; a Node app without the route gets a message saying so.

Each run wakes the app if it is asleep. With **Run queued jobs and scheduled tasks on their own instance** on in **Overview** → **App** card → **Sleep, scaling, region**, scheduled runs go to a separate jobs instance instead of a web instance.

## When a run does not happen

- Scheduled tasks stop while the organization is paused. See [Paused accounts](/docs/paused-accounts).
- A schedule added in the dashboard does nothing until the next deploy.
- A static or hybrid app needs middleware that exports `scheduled`.
- Check that the schedule is within the first five.

## Related

- [Queue workers](/docs/queue-workers)
- [Edge middleware](/docs/edge-middleware)
- [Configuration files](/docs/configuration-files)
- [Scaling & sleep](/docs/scaling-and-sleep)
