zerolith.ioChecking…

← All articles

Tutorial · Cron · Billing

A serverless cron that costs nothing between runs

5 min read

A job that runs once a day doesn't need a server that runs all day. Deploy a function, attach a cron expression, and look at what it actually costs: the seconds it executes, and nothing else.

There's one category of job almost everybody hosts badly: the one that runs once a day. A morning report, a third-party API to poll, a cache to warm, a backup to kick off. The work takes two seconds; the machine that does it runs for 86,400.

Which puts the ratio between what you pay for and what you use at somewhere around 43,000 to 1. Here's how to run the same job as a scheduled function, and more usefully what it actually costs, arithmetic included, including the part most serverless write-ups forget to mention.

The function

A Zerolith function is a handler. No framework to instantiate, no server to boot: a Python function that takes a request and returns whatever it likes.

The example job watches a page and reports when its content changes shape, the kind of check you want running daily without having to think about it. requests already ships in the runtime image, alongside httpx, pydantic, PyYAML and the database client. Nothing to install, nothing to build.

main.py
import os
import requests

WATCHED = os.environ.get("WATCHED_URL", "https://example.org/")
NOTIFY = os.environ.get("NOTIFY_WEBHOOK")  # Slack, Discord, Mattermost…


def handler(request):
    r = requests.get(WATCHED, timeout=10)
    ok = r.status_code == 200 and "Learn more" in r.text

    if not ok and NOTIFY:
        requests.post(NOTIFY, json={"text": f"{WATCHED} changed (HTTP {r.status_code})"},
                      timeout=10)

    # A dict comes back as JSON with a 200.
    return {"url": WATCHED, "status": r.status_code, "ok": ok}

The full handler contract (request.method, request.headers, request.query, request.json(), and the accepted return shapes: dict, (status, body), (status, body, headers)) is in the documentation.

The watched URL and the webhook are environment variables rather than constants in the code. The webhook is a secret, and a variable created as a secret can never be read back, not through the UI and not through the API: it's mounted into the pod from a write-only object.

create the secret
# The value goes to the cluster and never comes back out.
curl -X POST -H "Authorization: Bearer $JWT" \
     -d '{"name": "NOTIFY_WEBHOOK", "kind": "secret", "value": "https://hooks.slack.com/…"}' \
     https://zerolith.io/api/env-vars

Deploying it

One call deploys a function. size defaults to small, which is 128 MiB of memory and 0.1 reserved vCPU, generous for one HTTP call and a string comparison.

deploy
curl -X POST -H "Authorization: Bearer $JWT" \
     -d '{"name": "daily-watch",
          "language": "python",
          "size": "small",
          "code": "…contents of main.py…",
          "env_var_ids": ["<secret-id>"]}' \
     https://zerolith.io/api/functions

At this point the function is reachable over authenticated HTTP and no pod is running. That's the starting state, not an optimisation: min_scale is 0, so until someone calls, there's nothing to bill.

The schedule

The trigger is a standard five-field cron expression, in UTC, attached to the function:

schedule it
# Every day at 07:00 UTC.
curl -X POST -H "Authorization: Bearer $JWT" \
     -d '{"cron": "0 7 * * *"}' \
     https://zerolith.io/api/functions/<id>/schedules

The response carries next_run_at, the exact instant of the next fire, computed platform-side. After the first one, the same resource carries last_run_at and last_status, a compact summary of the last fire ("200", "skipped: no credit", "error: timeout"). That field is the one to look at when a cron "didn't run": most of the time it ran and it failed, which is a different problem.

A PATCH on /api/schedules/<id> with {"enabled": false} pauses the trigger without destroying the function. The limit is 100 schedules per account.

Two classic traps, because neither is guessable:

  • UTC, always. No time zone, no daylight saving. 0 7 * * * lands at 08:00 or 09:00 in Paris depending on the season. If local time matters to you, pick which of the two you want and accept the other one's drift.
  • The dispatcher gives up on a fire after 30 seconds, regardless of the function's own timeout_seconds (up to 600, 120 by default). So a cron isn't the tool for a ten-minute job; it's the tool for starting one.

What it actually costs

The prices are public and per-unit: €0.000004 per GiB-second of memory, €0.0000125 per vCPU-second, €0.0000004 per invocation. The small preset reserves 0.125 GiB and 0.1 vCPU, which per second of live pod gives:

cost per second
memory   0.125 GiB × €0.000004   =  €0.0000005  /s
cpu        0.1 vCPU × €0.0000125 =  €0.00000125 /s
                                    ─────────────────
                                     €0.00000175 /s

Now the part I haven't seen written down anywhere: two seconds is not what you're paying for.

A pod doesn't vanish the millisecond the response leaves. Knative's autoscaler watches a stable window, 60 seconds by default and tunable per function, and then allows a further grace period before scaling to zero. So for a two-second fire, budget something like a minute and a half of billed pod. That's a cron's real billing unit, and it's the number to reason with:

one daily cron, over a month
per fire    ≈ 90 s × €0.00000175  ≈ €0.000158   (+ €0.0000004 for the invocation)
30 fires    ≈ €0.0047

Half a cent a month. The €1 credit granted at sign-up covers this cron for more than ten years.

The honest comparison isn't "serverless versus a VPS" in the abstract: a €5/month VPS gives you a persistent machine, a disk, state, and neighbours you chose. What the arithmetic does say is that for this specific case, a stateless job, a few seconds, a few times a day, you were paying a factor of a thousand too much, and that factor came entirely from the time when nothing was happening.

You can redo the sum against your own account: GET /api/usage/summary returns billed requests, GiB-seconds and the current month's cost, and GET /api/usage/windows?function_id=<id> breaks it down window by window.

What still holds when things go wrong

A cron fire is an ordinary call, and it passes through exactly the same gates:

  • No credit, nothing wakes up. The credit check sits at the network edge, ahead of the activator: an empty account starts no pod, and the trigger records skipped: no credit. A forgotten cron can't dig a negative balance.
  • A disabled or suspended account doesn't fire. The same rules as any external call.
  • The first call after an idle spell pays a cold start. For a cron that doesn't matter at all, nobody is waiting for the response. This is exactly the load profile where scale-to-zero costs nothing in comfort.

Where to go next

Cron triggers have no screen in the UI yet: you drive them from the API, or from an AI agent connected to the MCP server, which can create both the function and its schedule in one conversation.

If you want the same exercise with persistent state instead of a webhook, the logical next step is the private database: same billing model, same sleep behaviour, and a database only your own functions can reach.

The full rates are on the pricing page, and an account opens with enough credit, as we just worked out, to leave this cron running far longer than you'll remember it exists.

Try it on your own account

Signing up comes with credit, enough to deploy, schedule and measure everything above, without getting a card out.

Get started »