A nightly cleanup job for an Express app: which scheduler, which cron expression
Old uploads, old logs and old rows need three different answers. What we'd run on Infrai for each, the cron expression, and the run history that tells you it worked.
Split the job before you pick a scheduler, because “delete old uploads, logs and records” is three problems wearing one coat. Old objects in a bucket want a lifecycle rule and no job at all. Old rows in Postgres want a scheduler that calls your Express app. Old application logs usually already have a retention setting you forgot to turn on. On Infrai the first is one call to POST /v1/storage/bucket/set_lifecycle/{bucket}, the second is POST /v1/cron/create with 0 3 * * *, and both are free.
Getting that split right removes most of the work. A cleanup endpoint that only has to delete database rows is a small, fast, restartable thing; one that also has to walk a million objects in a bucket is a distributed systems problem you didn’t need.
The uploads don’t need a cron job
Storage lifecycle rules run in the storage layer on the vendor’s schedule. No process of yours has to be awake, no run can time out, and there’s nothing to retry.
curl -s -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kh-cleanup-demo \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H 'content-type: application/json' \
-d '{"rules":[{"prefix":"uploads/tmp/","expire_days":7},{"prefix":"exports/","expire_days":30}]}'
{
"bucket_id": "bkt_e49e9ba969be460997dcba",
"name": "kh-cleanup-demo",
"vendor": "cos",
"region": "ap-singapore",
"lifecycle_rules": [
{ "prefix": "uploads/tmp/", "expire_days": 7 },
{ "prefix": "exports/", "expire_days": 30 }
]
}
Two things to know before you rely on that. The submitted set replaces the bucket’s whole rule list — read the current rules, edit them, and send them all back, or the ones you left out are gone. And transition_class is vendor-translated: the class names a Tencent COS bucket accepts are not the ones an S3 bucket accepts, so confirm the value against your bucket’s backend before you depend on a transition. expire_days is the portable half of the rule.
expire_days has a floor of 1. If you need sub-day expiry, this doesn’t support it, and you’re back to a job.
The records do need one
Here’s the scheduler decision, honestly. node-cron runs inside your Express process. That’s the simplest thing that works and it’s genuinely fine for a hobby deployment — but it dies with the process, so a deploy at 02:59 means tonight’s cleanup never happened, and if you scale to three replicas you get three concurrent deletes racing each other.
| node-cron in-process | system crontab | Infrai POST /v1/cron/create | |
|---|---|---|---|
| Survives a deploy / restart | No | Yes | Yes |
| Fires once across N replicas | No | Yes, on one box | Yes |
| Run history you can query | No | Whatever you log | GET /v1/cron/runs/list/{id} |
| Retries on failure | Hand-rolled | No | retry, 0–10 |
| Overlap protection | Hand-rolled | No | overlap_policy |
| Needs a machine you keep alive | Yes | Yes | No |
QStash and Inngest solve the same shape and both have richer workflow primitives than a plain scheduler — if your cleanup grows fan-out steps and human approvals, they’re the better tool and we’d say so. What you get here instead is that the scheduler, the bucket, the queue and the error tracker are one credential and one invoice.
The job itself:
curl -s -X POST https://api.infrai.cc/v1/cron/create \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "nightly-cleanup-0300",
"cron_expr": "0 3 * * *",
"task": "https://app.example.com/internal/jobs/cleanup",
"timezone": "UTC",
"timeout_seconds": 60,
"retry": 1,
"overlap_policy": "skip",
"payload": {"job": "cleanup", "retention_days": 30},
"headers": {"X-Cleanup-Token": "rotate-me"}
}'
The request field is task; the job record you get back names the same thing task_url. Send task on the way in and read task_url on the way out.
{
"job_id": "cron_6JQDns9hO4NMVTrXRfm31t9d",
"name": "nightly-cleanup-0300",
"cron_expr": "0 3 * * *",
"task_type": "http_url",
"task_url": "https://app.example.com/internal/jobs/cleanup",
"timezone": "UTC",
"overlap_policy": "skip",
"enabled": true,
"status": "active",
"next_run_at": null
}
0 3 * * * is 03:00 every day: minute, hour, day-of-month, month, day-of-week. Pick an hour when your database is quiet, and avoid exactly midnight — everybody’s job runs at midnight. timezone takes an IANA name and defaults to UTC, so write Europe/London rather than BST or GMT+1; daylight saving is the reason the field exists at all. And if you want the job to fire exactly once instead, send run_at with an absolute timestamp in place of cron_expr — the two are mutually exclusive.
The endpoint on the Express side
timeout_seconds maxes out at 900. A cleanup that deletes ten million rows will not finish in fifteen minutes, so the endpoint should delete a bounded batch, report what’s left, and let tomorrow’s run (or an immediate re-trigger) continue. Deleting in slices also keeps your locks short.
import express from "express";
import pg from "pg";
const app = express();
app.use(express.json());
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const TOKEN = process.env.CLEANUP_TOKEN;
if (!TOKEN) throw new Error("CLEANUP_TOKEN is not set");
const BATCH = 5000;
const BUDGET_MS = 45_000; // stay well inside timeout_seconds
app.post("/internal/jobs/cleanup", async (req, res) => {
if (req.get("X-Cleanup-Token") !== TOKEN) return res.status(401).json({ error: "bad token" });
const retentionDays = Number(req.body?.retention_days ?? 30);
const startedAt = Date.now();
let deleted = 0;
let more = true;
try {
while (more && Date.now() - startedAt < BUDGET_MS) {
const { rowCount } = await pool.query(
`DELETE FROM audit_events
WHERE ctid IN (
SELECT ctid FROM audit_events
WHERE created_at < now() - ($1 || ' days')::interval
LIMIT $2
)`,
[retentionDays, BATCH],
);
deleted += rowCount;
more = rowCount === BATCH;
}
res.json({ ok: true, deleted, more, elapsed_ms: Date.now() - startedAt });
} catch (err) {
console.error("cleanup failed", err);
res.status(500).json({ ok: false, deleted, error: String(err.message ?? err) });
}
});
app.listen(3000);
Two details that matter more than the SQL. The token check is not decoration — the URL you hand to a scheduler is a public endpoint that deletes data, and headers is the field that lets you authenticate the caller. (There’s also a secret field for HMAC signing, which returns only a fingerprint; check the API reference for the header it signs into before you depend on it.) And return a small JSON body, because the run record stores your response verbatim.
Proving it ran
This is the part node-cron can’t give you. Every execution is a row:
curl -s https://api.infrai.cc/v1/cron/runs/list/cron_6JQDns9hO4NMVTrXRfm31t9d \
-H "Authorization: Bearer $INFRAI_API_KEY" | jq '.data.items[0]'
{
"run_id": "cronrun_xJQWAc6ICayvkFQS8kaEj4wR",
"status": "failed",
"fired_at": "2026-07-26T06:03:07.398329Z",
"duration_ms": 0,
"http_status": 405,
"error": "HTTP 405",
"error_code": "CRON_TASK_URL_UNREACHABLE",
"skipped_reason": null,
"is_manual_trigger": true
}
That’s a real run from our own test job, pointed deliberately at a URL that refuses POST. Read http_status first when you’re debugging — it’s what your endpoint actually answered, and it separates “the scheduler never reached me” from “my handler threw”. The row also carries an output field holding your endpoint’s response body, which is the argument for returning a small JSON summary rather than a stack trace.
overlap_policy is worth setting deliberately. With the default skip, a second trigger while the first is still running is refused and recorded with status skipped and skipped_reason: "overlap_skip" — you can see it happened. queue makes the second run wait for the first to finish, so its duration_ms covers the wait as well as the work — worth knowing before you alert on that number. allow runs both concurrently, which for a delete job is usually the wrong answer.
Check the whole schedule and the price structure in one pass — every cron route here is free and rate-limited rather than metered, POST /v1/storage/object/delete_batch/{bucket} is free too (capped at 1000 keys per call), and new accounts start with $2 of free credit for the routes that do bill. Rates move downward over time, so read them rather than trusting a page:
curl -s https://api.infrai.cc/v1/cron/list \
-H "Authorization: Bearer $INFRAI_API_KEY" | jq '[.data.items[] | {name, cron_expr, status, last_run_status}]'
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer $INFRAI_API_KEY" | jq '[.capabilities[] | select(.module == "scheduling") | {id, billing}]'
And when the job has served its purpose, POST /v1/cron/pause/{id} halts it without losing the history; DELETE /v1/cron/delete/{id} removes it outright.
curl -s https://api.infrai.cc/v1/cron/get/cron_6JQDns9hO4NMVTrXRfm31t9d \
-H "Authorization: Bearer $INFRAI_API_KEY" | jq '{status: .data.status, enabled: .data.enabled, last: .data.last_run_status}'
If that returns "failed" two mornings running and you haven’t looked at runs/list, the cleanup isn’t happening — and the disk graph will tell you eventually, just later than you’d like.