# Cron Jobs on Render, Railway, and Fly.io: How Each Platform Runs Them

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/paas-cron-jobs

> How Render, Railway, and Fly.io run scheduled jobs: setup, UTC schedules, overlap rules, run limits, failure alerts, and how to catch runs that never happen.

**Render runs cron jobs as a dedicated Cron Job service with a five-field schedule in UTC, Railway runs any service on a Cron Schedule in UTC and expects the process to exit, and Fly.io offers scheduled Machines that start roughly every hour, day, week, or month from when you created them, or a cron runner you deploy yourself: Supercronic or Fly's Cron Manager.** The platforms differ on what happens when the previous run is still going, how long a run may last, and who hears about a failure: Render can email you about a failed run, Railway lists failed and skipped runs, and Fly.io "doesn't include built-in alerting on metrics." None of their docs describes an alert for a run that never starts. Each platform's behavior below was checked against its official documentation on October 25, 2026.

## Render, Railway, and Fly.io at a Glance

| Option                | Schedule                                            | Previous run still going         | Run limit                          | Built-in alert                         |
| --------------------- | --------------------------------------------------- | -------------------------------- | ---------------------------------- | -------------------------------------- |
| Render Cron Job       | Cron expression, UTC                                | Next run waits until it finishes | Stopped after 12 hours             | Email or Slack when an execution fails |
| Railway Cron Schedule | Cron expression, UTC, runs at least 5 minutes apart | Next run is skipped              | None; Railway doesn't terminate it | Webhooks on deployment status changes  |
| Fly scheduled Machine | `hourly`, `daily`, `weekly`, or `monthly`, fuzzy    | Not documented                   | Not documented                     | None                                   |
| Supercronic on Fly    | Crontab, in the container's time zone               | Waits for it to finish           | None                               | None; exit codes go to the logs        |
| Fly Cron Manager      | Five-field cron                                     | Starts another Machine           | `command_timeout`, 30 s default    | None described                         |

## Render: A Dedicated Cron Job Service

A cron job is its own service type, created in the dashboard or in a Blueprint with `type: cron` and a required `schedule` ([Blueprint spec](https://render.com/docs/blueprint-spec)):

```yaml
services:
  - type: cron
    name: nightly-report
    runtime: python
    schedule: '30 3 * * *'
    buildCommand: pip install -r requirements.txt
    startCommand: bin/nightly-report
    envVars:
      - key: CRONRADAR_API_KEY
        sync: false
```

`sync: false` keeps the secret out of the file: Render prompts for the value when the Blueprint is first created and ignores the entry on later syncs, so add secrets to an existing service by hand. The rest comes from Render's [Cron Jobs page](https://render.com/docs/cronjobs):

- **UTC.** "All day and time ranges use UTC."
- **The command has to exit.** "Make sure to use a command that exits when it's done!" Billing is prorated by the second of running time, with a minimum of $1 per cron job service a month.
- **One run at a time.** "Render guarantees that at most one run of a given cron job is active at a given time." If a run is still active when the next is due, Render delays the next run until the active one finishes.
- **A 12-hour ceiling.** "Render stops an active run after 12 hours." The Runs page shows each run's history and logs.
- **Failure notifications.** "A cron job execution fails" is one of the events Render sends by email or Slack at the **Only failure notifications** level ([notifications](https://render.com/docs/notifications)). Pro workspaces and above can also receive `cron_job_run_started` and `cron_job_run_ended` webhooks, the latter with a status of `succeeded`, `failed`, or `canceled` ([webhooks](https://render.com/docs/webhooks)).

## Railway: A Cron Schedule on Any Service

Railway has no separate service type. In a service's **Settings**, enter an expression under **Cron Schedule**, and Railway runs the service's start command on that schedule ([Cron Jobs](https://docs.railway.com/cron-jobs)):

- **UTC, at least 5 minutes apart.** The gap applies to every pair of runs, across the hour too: `*/5 * * * *` is valid, but `*/7 * * * *` isn't, because 10:56 and 11:00 are 4 minutes apart.
- **Not to the minute.** "Railway does not guarantee execution times to the minute as they can vary by a few minutes." The `railway cron list` example in Railway's docs shows a `0 0 * * *` job starting between 00:01:53 and 00:04:19 UTC.
- **A run that doesn't exit blocks the next.** "Railway does not automatically terminate deployments. As a result, if a previous execution is still running when the next scheduled execution is due, Railway will skip the new cron job." An open database connection is enough to keep the process alive.
- **Run history in the CLI.** [`railway cron list`](https://docs.railway.com/cli/cron) mirrors the dashboard's Cron Runs tab, with each run's status (`EXITED`, `CRASHED`, `SKIPPED`, and others), start time in your local time zone, duration, and a `railway logs` command for the latest run:

```bash
railway cron list --service nightly-report --limit 50
railway cron list --service nightly-report --json | jq -e 'all(.status != "CRASHED")'
```

- **Config files are being retired.** `railway.json` and `railway.toml` accept `deploy.cronSchedule` ([reference](https://docs.railway.com/config-as-code/reference)), but Config as Code is deprecated: new services can't opt in, and existing files stop being read on December 1, 2026 ([Infrastructure as Code](https://docs.railway.com/infrastructure-as-code)). Its replacement's [reference](https://docs.railway.com/infrastructure-as-code/reference) lists no cron field, so set the schedule in **Settings**.
- **Alerts.** Railway's [alerting guide](https://docs.railway.com/guides/alerts-crashes-failed-deploys) covers project webhooks for deployment status changes, including `Crashed`, delivered "best-effort" ([webhooks](https://docs.railway.com/observability/webhooks)). The cron docs describe no alert for a crashed or skipped run.

## Fly.io: Scheduled Machines, Supercronic, or Cron Manager

Fly's [task scheduling guide](https://docs.fly.io/blueprints/task-scheduling) compares scheduled Machines, Supercronic, Cron Manager, and in-app schedulers, and calls Cron Manager "the most production-hardened option." With any of them, an app's secrets are environment variables on every Machine in the app ([secrets](https://docs.fly.io/apps/secrets)).

### Scheduled Machines

`fly machine run --schedule` creates a Machine that starts on its own. Here `.` builds the image from the local Dockerfile, and `bin/nightly-report` replaces its CMD:

```bash
fly apps create example-jobs
fly secrets set CRONRADAR_API_KEY=ck_app_12345_abcdef --app example-jobs
fly machine run . bin/nightly-report --app example-jobs --region iad \
  --schedule daily --restart no
```

Set the secret first: `fly secrets set` updates every Machine in the app, which restarts it. What Fly documents about the schedule ([fly machine run](https://docs.fly.io/machines/flyctl/fly-machine-run)):

- **Fuzzy, counted from creation.** The flag "starts the Machine on a fuzzy `hourly`, `daily`, `weekly`, or `monthly` cycle." The first start is when you run the command, then "once per (approximate) hour, day, week, or month," and the "interval starts at time of Machine creation" ([Machine config](https://docs.fly.io/machines/api/machines-resource)). There's no clock time and no cron expression; the summary table in Fly's guide says "Not for precise timing."
- **No manual runs.** "Scheduled machines cannot be started via flyctl or Machines API commands."
- **A start can fail.** If the Machine's host doesn't have the resources when its time comes, "you'll get an error back. It's up to you to build the appropriate level of redundancy into your apps."
- **Failed runs are retried by default.** Machines with a schedule default to the `on-fail` restart policy, which restarts a Machine that exits non-zero up to 10 times within 5 minutes ([restart policy](https://docs.fly.io/machines/guides-examples/machine-restart-policy)). Keep that only for a job that's safe to repeat; `--restart no` turns it off.

### Supercronic in a Process Group

Fly's [Supercronic blueprint](https://docs.fly.io/blueprints/supercronic) runs a crontab inside your app's image, as a separate process group:

```text
30 3 * * * /app/bin/nightly-report
```

Install Supercronic in the Dockerfile. This pins v0.2.51, the current release (October 21, 2026), with its published checksum; the image also needs `curl`:

```dockerfile
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.51/supercronic-linux-amd64 \
    SUPERCRONIC_SHA1SUM=1692066e350a88ff4d7d6474fe3c7a6c41ec5e89 \
    SUPERCRONIC=supercronic-linux-amd64 \
    TZ=UTC

RUN curl -fsSLO "$SUPERCRONIC_URL" \
 && echo "${SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}" | sha1sum -c - \
 && chmod +x "$SUPERCRONIC" \
 && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
 && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic

COPY crontab /app/crontab
```

Add the process group to `fly.toml`, keep `[[services]]` pointed at `processes = ["app"]` so the cron Machine isn't public, then deploy and scale the cron group to one Machine, because every copy runs every job:

```toml
[processes]
  app = "bin/server"
  cron = "supercronic /app/crontab"
```

```bash
fly deploy
fly scale count app=2 cron=1
```

From the [Supercronic README](https://github.com/aptible/supercronic/blob/v0.2.51/README.md):

- **Jobs inherit the environment**, so Fly secrets reach them, and their output and return codes go to stderr, which `fly logs` shows. Fly's log search keeps 7 days ([search logs](https://docs.fly.io/monitoring/search-logs), in beta).
- **No overlap.** Supercronic waits for a job to finish before scheduling it again and warns when it falls behind; `-overlapping` changes that.
- **Local time.** It reads `/etc/localtime` or `TZ`, hence `TZ=UTC` above. Run it once with `-debug` to confirm.

Deploys restart the cron Machine. Fly sends `SIGINT` by default and forces a shutdown after `kill_timeout`, 5 seconds by default and at most 300 ([fly.toml](https://docs.fly.io/reference/configuration)). Supercronic waits for running jobs on that signal, but a job longer than the timeout is cut off. At startup it computes each job's next run from the current time ([cron.go](https://github.com/aptible/supercronic/blob/v0.2.51/cron/cron.go)), so a run that was due during the restart doesn't happen. [Cron in Docker](https://cronradar.com/blog/cron-in-docker) compares Supercronic with running a cron daemon in a container.

### Cron Manager

[Cron Manager](https://github.com/fly-apps/cron-manager) is a small app you deploy with a `FLY_API_TOKEN` secret. A `schedules.json` file lists jobs, each with a five-field `schedule`, a target `app_name`, a `command`, a `command_timeout`, and a Machine `config`. At each scheduled time it boots a one-off Machine that runs the command and is removed. Notes from its source at [commit 6afda48](https://github.com/fly-apps/cron-manager/tree/6afda48c47356c0efabaa872ee4091a63043ff2b):

- **History lives inside Cron Manager.** SSH into its Machine and run `cm jobs list <schedule-id>` for each job's status, exit code, and times in UTC, or `cm jobs show <job-id>` for stdout and stderr.
- **`command_timeout` defaults to 30 seconds.** A job Machine still running past it is destroyed and the job marked failed ([monitor.go](https://github.com/fly-apps/cron-manager/blob/6afda48c47356c0efabaa872ee4091a63043ff2b/internal/cron/monitor.go)). Set it above your longest run.
- **No overlap check.** Each scheduled time provisions a new Machine whether or not the previous one has finished ([job.go](https://github.com/fly-apps/cron-manager/blob/6afda48c47356c0efabaa872ee4091a63043ff2b/internal/cron/job.go)).

## What None of Them Reports: The Run That Didn't Happen

The built-in signals describe runs that started. A Render run held behind a long one, a Railway run skipped behind a process that never exited, a scheduled Machine whose host couldn't start it, a Supercronic run due during a deploy: none of these sends anything. Only something that expects each run can notice it's missing. Retries are uneven too: Render's and Railway's cron pages describe none, while Fly's scheduled Machines rerun a failed job by default, so a job that may run twice should be [safe to repeat](https://cronradar.com/blog/idempotent-scheduled-jobs).

## Add a Check-In That Works on All Three

There's no CronRadar SDK for these platforms and no auto-discovery of their jobs, so each job reports with its own HTTP check-ins: `start`, then `complete` or `fail`. Store the CronRadar API key as a platform secret: a Render environment variable (`sync: false` above), a Railway service variable, which you can seal so its value "is never visible in the UI nor can it be retrieved via the API" ([variables](https://docs.railway.com/variables)), or a Fly secret. An environment variable is a fine home for it here, since there's no crontab whose mail could carry it; just keep it off the command line.

Commit this as `bin/nightly-report` with the executable bit. It needs `sh`, `curl`, and `timeout` from GNU coreutils. Render's native runtimes run on Debian 12 ([native runtimes](https://render.com/docs/native-runtimes)), whose coreutils include `timeout`; check any image you build yourself.

```bash
#!/bin/sh
# bin/nightly-report: runs daily at 03:30 UTC and reports each run to CronRadar
url="https://cron.life/api/v1/ping/nightly-report"
run_id="nightly-report-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=30 3 * * *" \
    --data-urlencode "gracePeriod=900" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
timeout -k 1m 45m python -m reports.nightly
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
elif [ "$rc" -eq 124 ]; then
  report fail --data-urlencode "message=nightly report hit its 45-minute timeout"
else
  report fail --data-urlencode "message=nightly report exited with code $rc"
fi
exit "$rc"
```

- **Monitoring can't break the job.** Each check-in gives up after 5 seconds and ignores its own errors, and the script exits with the job's status, so the platform's own run status stays accurate. Each run, including a Fly restart, reports under its own `run_id`.
- **The monitor creates itself.** Every check-in carries the schedule, so the first run creates the monitor and a deleted one returns on the next run. `schedule` and `gracePeriod` apply only when a check-in creates the monitor; change an existing monitor's values in its settings.

Render's `startCommand` and Railway's start command become `bin/nightly-report`, the Supercronic crontab line calls `/app/bin/nightly-report`, and a Cron Manager job sets `"command": "bin/nightly-report"` with `"command_timeout": 3000`, above the script's 45 minutes.

The script's own timeout matters because a run ended from outside, by Render's 12-hour stop, Cron Manager's `command_timeout`, or a Fly deploy, sends `start` and nothing else. CronRadar flags that as stuck only when the job's next scheduled time plus the grace period passes with no other check-in, and the next run's `start` resets that window. There's no per-job maximum-runtime setting; the timeout turns a hang into a `fail`, which alerts immediately.

CronRadar checks for overdue monitors once a minute. A monitor is `warning` once its expected time passes and becomes `critical`, with an incident, when it's past the grace period and at least two minutes late. Alerts go to email, Slack, a webhook, Discord, or PagerDuty. A monitor created by its first check-in can't flag a job that never runs, so for a new job, create the monitor in the dashboard first: it stays `pending` and sends a never-executed alert if its first expected run plus grace passes without a report.

## Map Each Platform's Schedule to the Monitor

CronRadar reads `schedule` as five-field cron in UTC ([schedule syntax](https://docs.cronradar.com/schedules)).

### Cron Expressions: Copy Them, With Three Exceptions

Render and Railway expressions, and a Supercronic crontab running with `TZ=UTC`, can be copied as they are. Cron Manager hands its schedules to the `cron` package in its Ubuntu 22.04 image ([Dockerfile](https://github.com/fly-apps/cron-manager/blob/6afda48c47356c0efabaa872ee4091a63043ff2b/Dockerfile)), which reads them in that Machine's local time, so after the first runs, check that `cm jobs list` shows them at the UTC times you expect. The exceptions:

- **Sunday as 7.** Railway's docs accept 0 to 7 for the day of week, with 7 as Sunday; CronRadar needs `0`.
- **Supercronic extensions.** It accepts seconds and year fields and shortcuts like `@hourly`. Send `0 * * * *` for `@hourly`; a sub-minute schedule can't be monitored.
- **Both day fields restricted.** CronRadar expects a run only on dates matching both, Linux cron runs on either, and these platforms don't say which they follow. Restrict only one, or split the job into two schedules, each with its own monitor key.

With a `start` check-in, the grace only has to cover how late a run starts, including Railway's few minutes of variance and Render's delay behind a long previous run. 900 seconds is a reasonable start for daily and hourly jobs. For a job every 5 or 10 minutes, keep the grace under the interval: each check-in moves the expected time to the next scheduled run, so with a grace of about a full interval, the next run checks in before one skipped run is flagged. [Grace period sizing](https://cronradar.com/blog/cron-grace-period) covers measuring it.

### Fly's Fuzzy Cycles: Schedule the Earliest Start

A scheduled Machine has no cron expression to copy, and Fly doesn't say how far a run can drift. Build the monitor's schedule from the Machine's history: `fly machine status <machine-id>` shows its **Created** time and an event log with each start ([app information](https://docs.fly.io/apps/info)).

Put the monitor's scheduled time at or before the earliest start you've seen. CronRadar sets the next expected time to the schedule's first occurrence after each check-in. A run whose last check-in lands after the scheduled time moves the expectation to the next occurrence, as it should. A run that finishes before the scheduled time moves it only to that same occurrence, which then passes with nothing arriving and turns `critical`, although the job ran.

Suppose you create a daily Machine at 14:20 UTC. The first run happens at creation and creates the monitor, so set the script first: `schedule=0 14 * * *` and `gracePeriod=3600`. If two weeks of starts then fall between 14:18 and 14:31, each lands inside the hour of grace; the monitor shows `warning` for those minutes and stays out of `critical`, and a missed day goes `critical` around 15:00. If starts creep toward 14:00 or past 15:00, change the monitor's settings and keep the script in step. For an hourly Machine created at :20 with the same spread, use `10 * * * *` and `gracePeriod=1800`, under the interval.

If starts won't stay in a window, no schedule fits cleanly: an hourly job on `0 * * * *` with a grace just over an hour never alarms falsely but may let one skipped run pass unflagged. For timing that matters, use Supercronic or Cron Manager, whose expressions CronRadar can mirror exactly. [Heroku Scheduler](https://cronradar.com/blog/heroku-scheduler) and [Vercel Cron Jobs](https://cronradar.com/blog/vercel-cron-jobs) have coarse schedules with similar trade-offs.

## Frequently Asked Questions

### How do I run a cron job on Render?

Create a Cron Job service in the dashboard, or add a `type: cron` service with a `schedule` and `startCommand` to `render.yaml`. The schedule is a cron expression in UTC, and the command must exit when it's done. Store secrets as environment variables, with `sync: false` in a Blueprint.

### Does Railway support cron jobs?

Yes. Set a crontab expression in a service's **Cron Schedule** setting, and Railway runs its start command on that schedule in UTC, with runs at least 5 minutes apart. The process must exit, because a run due while the previous one is still going is skipped.

### How do I schedule a job on Fly.io?

Use `fly machine run <image> <command> --schedule daily` (or `hourly`, `weekly`, `monthly`) for a fuzzy cycle counted from the Machine's creation. For real cron expressions, run Supercronic as a process group scaled to one Machine, or deploy Fly's Cron Manager app, which starts a one-off Machine per scheduled run.

### What time zone do PaaS cron jobs use?

Render and Railway read schedules in UTC, so a local-time job needs the UTC offset written in, and its local time moves when daylight saving starts or ends. Fly's scheduled Machines have no clock time, only intervals from creation. Supercronic uses the container's time zone, so set `TZ=UTC` to keep it unambiguous.

### How do I get alerted when a PaaS cron job fails or doesn't run?

Render can notify you when a run fails, but none of the three documents an alert for a run that never starts. Have each run report `start`, then `complete` or `fail`, to a monitor such as CronRadar, which alerts immediately on `fail` and goes `critical` when an expected run doesn't report within its grace period.
