Back to blog

Alternatives · Feb 6, 2026

Fly.io cron alternative: schedule HTTP calls without an always-on machine

Fly.io has no scheduled-HTTP primitive. Scheduled Machines are coarse, and an always-on worker is a full Machine you pay for just to fire one HTTP call. Here is the external cron pattern that puts the clock outside Fly with per-IANA timezones, per-job failure alerts, and no always-on Machine cost.
crontap.com / blog
Fly.io has no scheduled-HTTP primitive. Scheduled Machines are coarse and per-machine, and an always-on worker is a full Machine you pay for to fire one HTTP call. Here is the external cron pattern that puts the clock outside Fly: every 1 minute on Pro, per-IANA timezones, no always-on machine for sparse cadences, one dashboard across every Fly app and every non-Fly target.

You shipped on Fly.io because it gave you a real Linux box per region without making you learn three layers of cloud abstractions first. Then your product manager asked for the small thing: "Run the monthly billing reconciliation on the 2nd of every month at 00:35 in Asia/Bangkok." You opened the Fly dashboard, looked for the Cron tab, and there is no Cron tab. There is no scheduled-HTTP primitive in Fly.io's runtime. You can run a Fly Machine on a coarse schedule, you can write a long-running container that runs node-cron inside, or you can put the clock outside Fly entirely. Here is what teams actually do and why most settle on the third option.

If you just want the short version: keep your Fly app, expose the work behind a POST route on your existing *.fly.dev domain (or a custom domain), check a bearer token in the handler, and point Crontap at the URL. You get every 1 minute on Pro, per-IANA timezones, per-job email / webhook (Slack / Discord / Telegram) failure alerts, no always-on machine for sparse cadences, and one dashboard across every Fly app and every non-Fly target you own.

Confirming Fly.io has no scheduled-HTTP primitive

The answer to "does Fly have cron" depends on what you mean. The Fly.io docs describe apps, machines, and a runtime layer. There is no first-class "schedule this HTTPS endpoint at this cadence" primitive. There is no equivalent of Vercel Cron, no equivalent of Cloud Scheduler, no equivalent of Heroku Scheduler.

Fly has scheduled Machines, which is a related but different feature. A Fly Machine can be configured with a coarse --schedule flag (hourly, daily, weekly, monthly) so the machine starts on that cadence, runs its entrypoint, and exits. For HTTPS-triggered work, that carries weight: you boot a whole machine, run a curl, exit. The cadence options are coarse (no every-minute, no specific time of day, no per-IANA timezone). The audit log is per-machine. You pay for every boot.

What Fly gives you is a long-running container that you can run on a coarse schedule. If you want a precise clock, you bring your own.

The three options teams try

Before reaching for an external scheduler, most Fly teams try the obvious ones. Two of the three work but cost more than they look like they cost; the third is what most teams settle on.

Option 1: Always-on machine running APScheduler or node-cron

You spin up a second Fly Machine that runs forever, with apscheduler (Python) or node-cron (Node) registering callbacks at the cadences you want. When a callback fires, it calls your main app's HTTP endpoint or runs the work in-process. This works, and you will see it in countless Fly tutorials. The catch is the bill and the operational shape:

  • The Machine is up 24/7, billing CPU and memory even when nothing is firing. For a handful of triggers per day or week, that is a full Machine worth of resources to be a clock.
  • The cron expressions live in code. Changing a cadence is a redeploy, the same redeploy story Vercel Cron has, except now you are also paying the always-on cost.
  • A crash silently kills every schedule. You either add a healthcheck and an alert, or you find out a week later that the monthly billing run did not run.
  • Multi-process gotchas. If you scale the worker beyond one Machine, you fire each schedule N times unless you add a leader-election layer.

For "I have 30 background jobs sharing a queue", a worker is the right answer. For "I have one monthly billing trigger", a worker is a heavyweight tool.

Option 2: Fly scheduled Machines

Fly Machines can be created with a --schedule flag of hourly, daily, weekly, or monthly. The machine starts on that cadence, runs its entrypoint, exits. This is the closest thing Fly ships to a built-in scheduler. The wall is what is missing:

  • The cadence options are the four listed above. No "every 15 minutes", no "every minute", no "at 02:00 local". For "monthly billing on the 2nd at 00:35 Asia/Bangkok", there is no expression that gets you there.
  • Timezone is not a configurable field. Scheduled Machines fire on a Fly-internal interpretation of the cadence, fine for "roughly once a day", not fine for "09:00 Europe/London".
  • The audit story is per-machine. There is no central dashboard of "every schedule across every Fly app".
  • Failure alerts route to the Fly app's logs by default. Slack or webhook alerts mean wiring it in via your own code.

For "back up this volume nightly", scheduled Machines are fine. For HTTP work that wants a precise time of day in a specific timezone, the gap is real.

Option 3: External HTTP cron

The third option puts the clock outside Fly entirely. An external scheduler holds the cadences, the timezones, the failure alerts, and the dashboard. Fly holds the runtime. The contract between them is one HTTPS call per cadence with a bearer token.

Crontap (cron)  ->  HTTPS POST  ->  https://your-app.fly.dev/api/jobs/your-job  ->  the actual work

This is the shape we recommend for HTTPS-triggered work, and it is the shape this guide walks through. The same pattern works on any platform that gives you a public URL for your app, which is exactly the pattern we walked through for Railway in a previous post since the platform shape is similar (no native scheduler, a public URL per service).

Why external cron wins for sparse cadences

The cost wedge is the easiest one to feel. If your Fly trigger is monthly, weekly, or even daily, an always-on Fly Machine is paying for ~720 hours of uptime to fire ~1 to ~30 times. For a Machine on the smallest paid CPU class plus a small memory footprint, that is a few dollars per month for a clock that ticks once a month. Crontap Pro is $3.25/mo billed annually for unlimited schedules with every-1-minute cadence; the wedge gets bigger the more schedules you add.

The architectural wedge is the bigger one for most teams:

  1. No always-on container. Your Fly app stays focused on serving requests. The scheduler does not also need to be a Fly app you run, deploy, and observe.
  2. Cadence changes are not redeploys. "Monthly on the 2nd" to "monthly on the 5th" is a dropdown in the dashboard. No PR, no review, no waiting for the build.
  3. Per-schedule timezone is a field. "Monthly on the 2nd at 00:35 Asia/Bangkok" is a single Crontap entry. No DST math.
  4. Failure alerts are first-class. When the Fly endpoint returns a 502 because the machine is mid-rolling-deploy, the alert lands in the channel of your choice with the response body and timing inline.
  5. One dashboard across apps and providers. A team running on Fly plus AWS Lambda plus a customer's Vercel app gets one cron tab for everything, not three.

The shape works whether your code is on Fly, on Render, on Cloud Run, or on a customer's VM. The schedule lives outside the deployment.

The setup, click by click

Here is the minimal walkthrough. Use it as a starting point.

Step 1: Deploy your Fly app normally

You probably already have a Fly app at something like https://your-app.fly.dev. If not, run fly launch and let Fly bootstrap a Machine for whatever your app needs (Node, Python, Go, Rust, Bun, Elixir, anything that speaks HTTP). Fly gives every app a public URL on *.fly.dev by default; that is the URL Crontap will hit. A custom domain works the same.

Step 2: Add an HTTP endpoint for the scheduled work

Create a POST route inside your existing app for each scheduled job. The route is the boundary; everything inside it is your normal code.

export async function POST(request: Request) {
  const auth = request.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  await runMonthlyBillingReconciliation();
  return Response.json({ ok: true });
}

Three things to call out:

  1. The route is POST so a casual GET from a crawler does not trigger anything.
  2. The token rides in the Authorization header, not a query string. The secret never lands in access logs.
  3. The handler returns 200 quickly. If the actual work is slow, kick off a background task, write to your audit log, and return immediately. The scheduler is not your queue.

Generate a strong random secret locally:

openssl rand -base64 32

Set it as a Fly secret on your app:

fly secrets set CRON_SECRET=<your-secret> --app your-app

The Machine restarts automatically once the secret is set. Confirm the route reads process.env.CRON_SECRET and you are ready.

Step 3: Crontap schedule pointing at the Fly URL

Head to Crontap and create a new schedule.

  1. URL. Paste the production URL of your endpoint, e.g. https://your-app.fly.dev/api/jobs/monthly-billing.
  2. Method. POST.
  3. Headers. Add Authorization: Bearer <your CRON_SECRET>. Crontap stores the value encrypted; you do not see it again after saving.
  4. Cadence. Type plain English ("monthly on the 2nd at 00:35") or paste a cron expression (35 0 2 * *). Crontap previews the next 5 fires inline so you can sanity-check before saving.
  5. Timezone. Pick the IANA zone that matches the schedule's intent. Asia/Bangkok for the monthly billing run, Europe/Berlin for a daily 08:00 digest, UTC for anything that is genuinely UTC.
  6. Failure alerts. Add an integration: email / webhook (Slack / Discord / Telegram). Crontap fires on 4xx and 5xx with the response body and timing in the payload.

Press Perform test to fire a real request before you trust the cadence. If the route returns 200, you are done. If you see 401, your bearer is mismatched. If you see 5xx, the route itself failed and the alerting just proved itself.

Fix this in 60 seconds with Crontap. Free tier available. No credit card. Schedule your first job.

Worked example: one monthly trigger, no always-on machine

Take a Fly.io customer running a single monthly trigger. The shape we have watched: one Fly app at https://your-app.fly.dev, one scheduled job that runs once a month on day 2 at 00:35 in Asia/Bangkok, no other scheduled work. The job hits an internal billing reconciliation route, reads recent usage from the database, writes invoices to the team's billing system, and returns 200 in under a second.

Three options for this team:

  1. Always-on worker Machine. Run a second Fly Machine all month to fire one HTTP call on the 2nd. Cost: a small Machine for ~720 hours plus the cognitive cost of "the worker is running, right?".
  2. Fly scheduled Machine. Cron a Machine to run monthly. The cadence options do not include "monthly at 00:35 Asia/Bangkok"; the closest expression fires once a month at a Fly-determined time, in UTC. Adjusting it to land at 00:35 local in Bangkok requires the team to bake the offset into a startup script and accept that DST does not apply (it does not in Bangkok, but the principle generalizes).
  3. Crontap external cron. One schedule pointing at https://your-app.fly.dev/api/jobs/monthly-billing, cadence 35 0 2 * *, timezone Asia/Bangkok, Slack failure alert. Total monthly cost: $3.25 a month billed annually for unlimited schedules.

Option 3 is what the team picked. The Fly app's main Machine handles the call when it fires, exactly the way it handles every other request to the app. No second Machine. No coarse cadence. The audit trail is in two places: Crontap's request and response history, and Fly's own app logs.

If the team adds a daily housekeeping job later, that is one more schedule in the same dashboard. If they add a per-tenant job at named local times, that is one schedule per tenant. The pattern scales without adding more Machines.

When to keep an always-on Fly machine

External cron is a shape, not a religion. The always-on Machine is the right answer when you have a fleet of background jobs sharing a queue (BullMQ, Sidekiq, Celery), when you are running event-driven workers that must react to messages on a Fly internal network, or when you have already invested in a worker process and the scheduling is a small addition to a system that already exists for other reasons.

For pure HTTPS triggers and sparse cadences, the external pattern reads cleaner.

FAQ

Does Fly.io really have no cron at all?

Correct, as of the Fly.io docs at the time of writing. Fly has scheduled Machines for coarse cadences (hourly, daily, weekly, monthly), and it has long-running Machines you can put a scheduler library inside, but there is no first-class HTTP scheduler tab in the dashboard.

Can I just run node-cron inside my main Fly app?

You can. The catch is that your main app is now coupled to scheduling: a deploy bounces the schedules, a crash drops them, and scaling beyond one Machine fires every job N times unless you add leader election. For a handful of HTTPS triggers, an external scheduler avoids all three problems.

Can Crontap call private Fly services?

Crontap is HTTPS-only and hits public URLs. Fly apps have a public URL by default (*.fly.dev), which is what Crontap targets. If you have configured an internal-only .flycast address, expose a thin public proxy that authenticates the bearer and forwards into the internal network. The bearer-in-handler pattern is the audit boundary.

Will scheduled Machines and Crontap conflict if I run both?

No. They run independently. Some teams keep a scheduled Machine for coarse jobs (a daily volume backup, a weekly housekeeping run) and add Crontap for everything that needs sub-day cadence, per-IANA timezone, or a failure alert in Slack.

What about Fly's fly machine run --schedule?

That is the same scheduled Machines feature, available via the CLI. Same coarse cadence options (hourly, daily, weekly, monthly) and the same per-machine audit story. Useful for "run this script once a day"; not useful for "fire this URL at 02:00 Europe/London".

What if my route takes 30 seconds to finish?

Do not block the scheduler waiting for the work. Have the route enqueue or kick off a background task, write to your audit log, and return 200 immediately. Crontap will respect a long-running response, but you do not want the scheduler to be your queue.

References

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.

Alternatives

Heroku Scheduler alternative: any cron expression without the add-on

Heroku Scheduler caps you at three cadences and account-wide UTC, and spins a one-off dyno per run. Here is the external cron pattern that gives you any cron expression, per-schedule timezones, and zero per-execution dyno spin-up cost.

Guides

Running an OpenAI sentiment pipeline on a real scheduler

OpenAI batch work needs a clock, not a user session. Here is the scheduled HTTP-route pattern teams use to drain LLM batches at a sustainable rate inside OpenAI's rate limits, with per-task failure alerts.

Reference

Cron syntax cheat sheet with real-world examples

Cron syntax without the math. Every pattern you're likely to reach for (every 5 minutes, weekdays, business hours, first of the month), with a practical example and a link to a free debugger.