Cron Jobs
A Ghayma cron job calls a route in your own deployed app on a schedule. The
platform holds the schedule and, when a job fires, makes an HTTP request to a
path you choose on your app’s platform domain. Your route does the work — a
nightly report, a cleanup, a digest email — and returns a 2xx.
There is no separate functions runtime to learn: a cron job is just scheduled traffic to code you’ve already deployed.
Quickstart
Create a job from the Dashboard:
- Open your project, then the site you want the job attached to.
- Go to the Cron Jobs tab and click New job.
- Fill in:
- Name — a short slug (e.g.
nightly-report), unique per site. - Schedule — a standard 5-field cron expression (e.g.
0 3 * * *for 03:00 every day). See Schedules. - Path — the route to call, e.g.
/api/cron/nightly. - Method —
GET(default) orPOST.
- Name — a short slug (e.g.
- Save.
On schedule, the platform calls https://<your-app> at that path — for the
example above, https://<your-app>/api/cron/nightly. Your route must return a
2xx status within the timeout (max 300s). Anything else is recorded as
a failed or timed-out run.
Cron requests reach a public route on your app. Anyone who knows the path can hit it too — so every job is signed, and your route must verify the signature before doing any work. See Verifying requests.
Config as code
Instead of clicking, you can declare cron jobs in your project’s .ghayma.json
and let each deploy reconcile them:
{
"crons": [
{
"name": "nightly-report",
"schedule": "0 3 * * *",
"path": "/api/cron/nightly",
"method": "GET",
"timezone": "Africa/Algiers"
}
]
}Fields
| Field | Required | Notes |
|---|---|---|
name | yes | Slug, ≤ 40 chars, unique per site. Jobs are matched by this name. |
schedule | yes | 5-field cron expression. See Schedules. |
type | no | http (default) or container. See Container jobs. |
path | http | The route the platform calls on your app. Required for HTTP jobs. |
method | no | HTTP jobs: GET (default) or POST. |
command | container | The command to run, via sh -c. Required for container jobs. |
tier_slug | container | The pod tier for a container job. See Container jobs. |
timezone | no | IANA name; defaults to Africa/Algiers. |
timeout_seconds | no | Seconds before the run is marked timeout. HTTP: default/max 300. Container: default 1800, max 3600. |
Reconciliation rules — read these carefully, they decide what a deploy does to your jobs:
- If the
cronskey is present, it is authoritative for that site. On every deploy, jobs are created or updated byname, and any job missing from the array is deleted. The array is the complete desired state. - If the
cronskey is absent, the deploy never touches cron jobs. Jobs you created in the Dashboard survive config-less deploys untouched. "crons": []deletes all cron jobs on that site — an empty array is still an authoritative (empty) desired state.- Per-job errors are warnings, never deploy failures. If one entry is invalid or can’t be admitted (points, object limit), it’s surfaced as a warning in the deploy log and skipped; the rest of the array still syncs and the deploy still succeeds.
- Secrets never live in the file. Each job’s signing secret is generated server-side and injected into your app as an environment variable — a config-created job gets one automatically, with nothing to copy. See Verifying requests.
Dashboard edits and config-as-code go through the same validation, so a job
behaves identically however it was created. Pick one source of truth per site:
if you manage a site’s jobs in .ghayma.json, manage them all there, since the
next deploy will delete anything not in the array.
Verifying requests (do this!)
Every cron request carries two headers:
X-Ghayma-Cron: nightly-report
X-Ghayma-Cron-Signature: t=1753099200,v1=6f3b…<hex>X-Ghayma-Cron— the job name.X-Ghayma-Cron-Signature— a Unix timestamptand a signaturev1, wherev1 = HMAC-SHA256(secret, "<t>.<path>")in lowercase hex. The signed string is the timestamp and the request path joined by a dot, e.g.1753099200./api/cron/nightly.
The secret is injected into your app for you. It is generated server-side and
delivered as an environment variable — there is no credential to copy and no
ghayma env set step.
The variable is per job, named CRON_SECRET_<JOB>: the job name
upper-cased, with every character that isn’t a letter or digit replaced by _.
Job names are lowercase letters, digits and dashes, so in practice that’s
uppercase with - → _:
| Job name | Environment variable |
|---|---|
drain-jobs | CRON_SECRET_DRAIN_JOBS |
nightly-report | CRON_SECRET_NIGHTLY_REPORT |
digest | CRON_SECRET_DIGEST |
Because the job name also arrives in the X-Ghayma-Cron header, derive the
variable name from that header instead of hardcoding it. Every snippet below
does this, and it keeps working if a job is ever recreated under a different
name — both sides move together.
An existing job needs one redeploy. The variables are written into your app at deploy time. Creating a job also patches the running app so it picks up the secret immediately, but a job created before this shipped — or one on a site that hasn’t deployed since — only receives its variable on the site’s next deploy. If your route is rejecting every run because the variable is missing, redeploy the site.
To verify an incoming request:
- Read the job name from
X-Ghayma-Cronand look up itsCRON_SECRET_<JOB>variable. If it isn’t set, reject the request — never fall back to an empty or default key. - Parse
tandv1from the signature header. - Reject replays: if
|now − t| > 300seconds, refuse the request. - Recompute
HMAC-SHA256(secret, "<t>.<path>")using the request path. - Constant-time compare it against
v1. Never use==on the raw hashes.
If they all pass, do the work and return 2xx. Otherwise return 401.
Always reject when the secret is missing, always compare with a constant-time
function (crypto.timingSafeEqual, hmac.compare_digest,
subtle.ConstantTimeCompare, hash_equals), and always enforce the
300-second replay window. All three checks appear in every snippet below —
keep them. An HMAC computed with an empty key is one anybody can forge.
A job’s secret can still be revealed in the Dashboard (job → Reveal secret),
but that is a debugging affordance and it is admin/owner only — read and
write collaborators get a 403. Normal setup never needs it.
Next.js (App Router route handler)
import crypto from "node:crypto";
// The platform injects one CRON_SECRET_<JOB> variable per job. Derive the name
// from the job in the X-Ghayma-Cron header rather than hardcoding it.
function secretFor(job: string): string | undefined {
if (!job) return undefined;
const name = "CRON_SECRET_" + job.toUpperCase().replace(/[^A-Z0-9]/g, "_");
return process.env[name] || undefined;
}
function verify(req: Request, path: string): boolean {
const secret = secretFor(req.headers.get("x-ghayma-cron") ?? "");
if (!secret) return false; // unknown job, or the site hasn't redeployed yet
const sig = req.headers.get("x-ghayma-cron-signature") ?? "";
const parts = Object.fromEntries(sig.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
const v1 = parts.v1 ?? "";
if (!t || !v1) return false;
// Replay window: reject signatures more than 300s old.
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${path}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function GET(req: Request) {
const path = new URL(req.url).pathname;
if (!verify(req, path)) {
return new Response("unauthorized", { status: 401 });
}
// ... your scheduled work ...
return new Response("ok"); // any 2xx, within 300s
}Express
const crypto = require("crypto");
// The platform injects one CRON_SECRET_<JOB> variable per job. Derive the name
// from the job in the X-Ghayma-Cron header rather than hardcoding it.
function secretFor(job) {
if (!job) return null;
const name = "CRON_SECRET_" + job.toUpperCase().replace(/[^A-Z0-9]/g, "_");
return process.env[name] || null;
}
function verifyCron(req, res, next) {
const secret = secretFor(req.get("X-Ghayma-Cron"));
if (!secret) return res.sendStatus(401); // unknown job, or not redeployed yet
const sig = req.get("X-Ghayma-Cron-Signature") || "";
const parts = Object.fromEntries(sig.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
const v1 = parts.v1 || "";
if (!t || !v1) return res.sendStatus(401);
// Replay window.
if (Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(401);
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${req.path}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
next();
}
app.get("/api/cron/nightly", verifyCron, async (req, res) => {
// ... your scheduled work ...
res.sendStatus(200);
});Python (Flask)
import hashlib
import hmac
import os
import re
import time
from flask import Flask, request, abort
app = Flask(__name__)
def secret_for(job: str) -> bytes | None:
"""The platform injects one CRON_SECRET_<JOB> variable per job. Derive the
name from the job in the X-Ghayma-Cron header rather than hardcoding it."""
if not job:
return None
name = "CRON_SECRET_" + re.sub(r"[^A-Z0-9]", "_", job.upper())
value = os.environ.get(name)
return value.encode() if value else None
def verify_cron() -> bool:
secret = secret_for(request.headers.get("X-Ghayma-Cron", ""))
if secret is None: # unknown job, or the site hasn't redeployed yet
return False
sig = request.headers.get("X-Ghayma-Cron-Signature", "")
parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
# Replay window.
if abs(time.time() - t) > 300:
return False
signed = f"{t}.{request.path}".encode()
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.get("/api/cron/nightly")
def nightly():
if not verify_cron():
abort(401)
# ... your scheduled work ...
return "ok", 200Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// cronSecret returns the secret the platform injected for this job. One
// CRON_SECRET_<JOB> variable exists per job; derive the name from the job in the
// X-Ghayma-Cron header rather than hardcoding it.
func cronSecret(job string) []byte {
if job == "" {
return nil
}
name := strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, strings.ToUpper(job))
v := os.Getenv("CRON_SECRET_" + name)
if v == "" {
return nil
}
return []byte(v)
}
func verifyCron(r *http.Request) bool {
secret := cronSecret(r.Header.Get("X-Ghayma-Cron"))
if secret == nil { // unknown job, or the site hasn't redeployed yet
return false
}
var t, v1 string
for _, part := range strings.Split(r.Header.Get("X-Ghayma-Cron-Signature"), ",") {
if kv := strings.SplitN(part, "=", 2); len(kv) == 2 {
switch kv[0] {
case "t":
t = kv[1]
case "v1":
v1 = kv[1]
}
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || v1 == "" {
return false
}
// Replay window.
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%d.%s", ts, r.URL.Path)
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(expected), []byte(v1)) == 1
}
func main() {
http.HandleFunc("/api/cron/nightly", func(w http.ResponseWriter, r *http.Request) {
if !verifyCron(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// ... your scheduled work ...
w.WriteHeader(http.StatusOK)
})
http.ListenAndServe(":"+os.Getenv("PORT"), nil)
}PHP
<?php
// The platform injects one CRON_SECRET_<JOB> variable per job. Derive the name
// from the job in the X-Ghayma-Cron header rather than hardcoding it.
$job = $_SERVER['HTTP_X_GHAYMA_CRON'] ?? '';
$secret = $job === ''
? false
: getenv('CRON_SECRET_' . preg_replace('/[^A-Z0-9]/', '_', strtoupper($job)));
// Unknown job, or the site hasn't redeployed yet.
if ($secret === false || $secret === '') {
http_response_code(401);
exit('unauthorized');
}
$sig = $_SERVER['HTTP_X_GHAYMA_CRON_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $sig) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
// Replay window.
if ($t === '' || $v1 === '' || abs(time() - (int) $t) > 300) {
http_response_code(401);
exit('unauthorized');
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$expected = hash_hmac('sha256', $t . '.' . $path, $secret);
if (!hash_equals($expected, $v1)) {
http_response_code(401);
exit('unauthorized');
}
// ... your scheduled work ...
http_response_code(200);
echo 'ok';Container jobs (heavy work)
An HTTP cron job is scheduled traffic to your running app — perfect for short periodic logic, but bounded by the same 300-second request timeout as any web request. When a job needs to do real work — tens of minutes of batch processing, not a quick handler — reach for a container job instead.
A container job runs a command in a one-off container built from your site’s
currently deployed image. It gets its own tier-sized pod with the full CPU and
RAM of that tier, and there’s no HTTP request in the loop, so the 300-second cap
doesn’t apply — it runs until it finishes or hits its own (much larger) timeout.
When the command exits 0, the run succeeds; a non-zero exit is a failed run.
Choose a container job when:
- the work takes 10+ minutes — an HTTP handler would time out at 300s;
- there’s no web server to hit — you just want to run a script;
- it’s a batch import/export, data migration, or report generation that needs its own CPU and memory rather than sharing your app’s request path.
Choose an HTTP job when the work is short and lives in a route you’ve already deployed — see Quickstart.
How it runs
- Image — the container is built from your site’s latest successfully deployed image. Deploy the site at least once first; a job on a site that has never deployed can’t run. It always uses the current image, so a redeploy is how you ship new job code.
- Command — your
commandruns throughsh -c, so shell features work as written: pipes, quotes,&&, environment expansion. For examplenode scripts/import.jsorpython -m jobs.nightly && echo done. - Tier —
tier_slugpicks the pod size from the same tiers your apps use. Pick the smallest tier that fits the job’s memory needs — it’s reserved capacity (see Pricing below). - Env vars — the container sees the same environment as your app (the site’s env vars), minus server-only values. Your secrets and config are already wired in; there’s nothing extra to set up.
- No signature. Nothing is called over HTTP, so there’s no signed header and no secret to verify. The verification steps above apply to HTTP jobs only.
Declaring a container job
Declare one in .ghayma.json with "type": "container", a command, and a
tier_slug (in place of an HTTP job’s path/method):
{
"crons": [
{
"name": "nightly-import",
"schedule": "0 2 * * *",
"type": "container",
"command": "node scripts/import.js",
"tier_slug": "s",
"timezone": "Africa/Algiers"
}
]
}The reconciliation rules are identical to HTTP jobs: with the
crons key present, the array is the authoritative desired state for the site.
You can also create a container job from the Dashboard — on the site’s Cron
Jobs tab, pick Container as the type and fill in the command and tier.
Logs & run history
Each run captures the container’s exit status, duration, and the last 8 KB
of its logs, shown in the job’s Runs drawer. Write progress and errors to
stdout/stderr and that tail is there when you need to debug a run. A run that
hasn’t finished by its timeout is recorded as timeout (see
Limits & pricing for the defaults and cap).
Scheduling is shared with HTTP jobs: one run at a time, missed firings skipped
not replayed, overlapping firings recorded as skipped_overlap. See
Schedules.
Pricing
A container job reserves one cron slot and its pod tier’s points when you create it — the tier is priced exactly like an always-on app of that tier, because the capacity has to be free for the job to start on schedule. Changing the tier re-prices; deleting the job refunds both the slot and the tier. See Points & Plans for how reservations work and Pricing by Service for the cron slot and tier costs.
Schedules
A schedule is a standard 5-field cron expression:
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *| Expression | Fires |
|---|---|
* * * * * | every minute |
*/15 * * * * | every 15 minutes |
0 3 * * * | daily at 03:00 |
0 9 * * 1 | Mondays at 09:00 |
0 0 1 * * | midnight on the 1st of each month |
Rules:
- Evaluated in the job’s timezone. The default is
Africa/Algiers; settimezoneto any IANA name to schedule in another zone. - Minimum interval is 1 minute. A schedule that would fire more often than once a minute is rejected.
- Missed firings are skipped, not replayed. If the platform restarts across a scheduled time, that firing is simply lost — there is no catch-up burst when it comes back.
- Overlapping firings are skipped. Only one run of a job is ever in flight at
a time. If a run is still going when the next firing is due, that firing is
recorded as
skipped_overlapand no second request is made.
Run history & manual runs
Open a job’s Runs drawer in the Dashboard to see its history: the last 50 runs, each with status, duration, and (for completed HTTP calls) the HTTP status your app returned. Run records are kept for 30 days.
Use Run now to fire a job immediately without waiting for its schedule — handy for testing your route. Manual runs respect the overlap rule: if a run is already in flight, the manual trigger is skipped just like a scheduled firing.
Statuses
| Status | Meaning |
|---|---|
running | The run is in flight. |
success | Your app returned a 2xx within the timeout. |
failed | Your app returned a non-2xx status (the HTTP status is recorded). |
timeout | Your app didn’t respond within the timeout (max 300s). |
skipped_overlap | The previous run was still in flight, so this firing was skipped. |
error | The platform couldn’t complete the request (e.g. the app was unreachable). |
Limits & pricing
- Points. Every cron job — HTTP or container — reserves one cron slot’s points against your plan budget when you create it, and refunds them when you delete it. A container job additionally reserves its pod tier’s points, priced like an always-on app of that tier; changing the tier re-prices and deleting refunds both. Like every marketplace object, a job is admitted only if it fits your budget. See Points & Plans for how reservations work and Pricing by Service for the cron slot and tier costs.
- Jobs per project are bounded by the platform’s standard object limit (the same anti-spam rail that caps databases and buckets) — on the order of a couple dozen objects per project.
- Timeout. HTTP jobs are capped at 300 seconds. Container jobs default to
1800 seconds (30 min) and can be raised to a hard cap of 3600 seconds
(1 hour). A run that hasn’t finished by its timeout is recorded as
timeout. - Method (HTTP jobs) is
GETorPOSTonly. - Target (HTTP jobs) is always your own app’s default platform domain (the
*.ghayma.appURL for that site). A cron job cannot target a custom domain or any external host — the destination is derived from the site it’s attached to, not from user input. - Image (container jobs) is your site’s latest deployed image — the site must have at least one successful deploy before a container job can run.
FAQ
Does a cron call wake a sleeping app? Yes. The request travels through normal production routing, so a plans-mode app that has scaled down wakes exactly as it would for any real visitor, then serves the request.
Can I run a schedule more often than every minute? No. One minute is the floor; anything finer is rejected when you save the job.
What happens to my jobs on redeploy?
Jobs persist across deploys. If your .ghayma.json has a crons key, each deploy
reconciles the site’s jobs to match that array (creating, updating, and deleting
by name). If the key is absent, deploys leave your jobs untouched. See
Config as code.
Which tier should I pick for a container job? The smallest tier whose memory comfortably fits the job. The tier is reserved capacity — its points are held for as long as the job exists, exactly like an always-on app of that size — so oversizing just costs you points for headroom you don’t use. You can change the tier later; it simply re-prices.
Does a container job wake my app? No. A container job runs in its own one-off pod built from your site’s image — your running app is never involved, woken, or touched. (An HTTP job is the opposite: it travels through normal routing and does wake a sleeping app.)
What image does a container job use? Your site’s latest successfully deployed image. You ship new job code by deploying the site, and a site that has never deployed can’t run a container job yet — deploy it once first.
Are response bodies stored? No. Only the status, HTTP status code, and duration of each run are kept — never the response body your app returns.