Docs / Tools
Migrating from system cron
A practical guide to moving crontab entries onto Tend schedules, including the semantic differences that catch people out.
Last updated April 22, 2026
#Why move off system cron
System cron is a fine tool with a narrow job: run a command on a machine at a time. It does not know whether the machine is alive, whether the previous run finished, whether the command succeeded, or whether anyone should be told. Most teams discover these gaps in production, usually at 3 a.m.
Tend replaces the machine-bound crontab with a hosted schedule that calls an HTTP endpoint you control. You keep the logic, we keep the clock. In exchange you get automatic retries with backoff, a concurrency limit so slow runs don't pile up, searchable run logs and signed delivery of results to a webhook.
- No single point of failure. A rebooted or replaced server no longer skips scheduled work.
- Failure is visible. Every run has a status, an HTTP response code and captured output metadata.
- Retries are declarative. 5 attempts by default, up to 25, instead of a shell loop wrapped around
curl. - Multiple instances are safe. Scaling your app to three replicas no longer triples your nightly job.
#Mapping crontab concepts to Tend
A crontab line is a schedule plus a command. In Tend, the schedule stays a schedule, and the command becomes an HTTP endpoint that performs the work. If your existing job is a script, the easiest path is a small authenticated route in your application that invokes it.
| System cron | Tend equivalent | Notes |
|---|---|---|
*/5 * * * * | cron: "*/5 * * * *" | Standard five-field syntax. Minimum interval is 30 seconds via the interval_seconds option instead. |
| Server timezone (often UTC) | timezone: "America/Denver" | Explicit IANA zone name. An unknown zone returns 400 invalid_cron_expression. |
MAILTO= for failures | Failure webhook or alert rule | Cron only mails stderr on nonzero exit. Tend tracks status, attempts and delivery separately. |
flock to prevent overlap | concurrency: 1 | Per-schedule concurrency setting; plan-wide limits are 5, 50, 500 or 2,000. |
@reboot | No equivalent | Startup work belongs in your deploy process, not a schedule. |
0 0 1 * * one-off tasks | Delayed job with run_at | For genuinely one-time work, create a job, not a schedule. |
| Shell command | HTTPS URL plus JSON payload | Payload limit is 256 KB after JSON encoding. |
#Creating your first schedule
Take a typical crontab entry: a nightly invoice reconciliation at 02:30 Mountain time, running a script on a single server.
# /etc/cron.d/billing
CRON_TZ=America/Denver
MAILTO=ops@northfork.example
30 2 * * * billing /opt/billing/bin/reconcile.sh >> /var/log/reconcile.log 2>&1The Tend version points at an endpoint on your service and names the schedule. Names are unique per environment, so a second create with the same name returns 409 schedule_name_taken, which is a feature: it prevents duplicate migrations from doubling the work.
curl -X POST https://api.tendcomputer.com/v2/schedules \
-H "Authorization: Bearer tnd_live_9fK2xQ7mVb3LpR8dWc1Zh" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: migrate-reconcile-2026-04-22" \
-d '{
"name": "nightly-invoice-reconcile",
"cron": "30 2 * * *",
"timezone": "America/Denver",
"url": "https://api.northfork.example/internal/reconcile",
"payload": {"scope": "invoices"},
"concurrency": 1,
"retry": {"max_attempts": 5, "base_seconds": 10, "cap_seconds": 3600},
"region": "us-west"
}'schedule = client.schedules.create(
name="nightly-invoice-reconcile",
cron="30 2 * * *",
timezone="America/Denver",
url="https://api.northfork.example/internal/reconcile",
payload={"scope": "invoices"},
concurrency=1,
retry={"max_attempts": 5, "base_seconds": 10, "cap_seconds": 3600},
region="us-west",
)
print(schedule.id) # sch_01J9C2VN5R8H3K7YQ4WTB6DXMAconst schedule = await client.schedules.create({
name: "nightly-invoice-reconcile",
cron: "30 2 * * *",
timezone: "America/Denver",
url: "https://api.northfork.example/internal/reconcile",
payload: { scope: "invoices" },
concurrency: 1,
retry: { maxAttempts: 5, baseSeconds: 10, capSeconds: 3600 },
region: "us-west",
});
console.log(schedule.id); // sch_01J9C2VN5R8H3K7YQ4WTB6DXMAschedule, err := client.Schedules.Create(ctx, &tend.ScheduleCreateParams{
Name: "nightly-invoice-reconcile",
Cron: "30 2 * * *",
Timezone: "America/Denver",
URL: "https://api.northfork.example/internal/reconcile",
Payload: map[string]any{"scope": "invoices"},
Concurrency: 1,
Region: "us-west",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(schedule.ID)#Semantic differences that will surprise you
Cron expressions look the same, but the surrounding behavior is not. Read this section before cutting over anything that matters.
- Success is defined by your endpoint. Cron treats exit code 0 as success. Tend treats a 2xx response as success. Return a 2xx only when the work is done or durably queued, otherwise Tend will record a success that isn't one.
- Timeouts are real. A run may execute for at most 15 minutes. Cron has no such limit, and long-running scripts often depend on that. For long work, return quickly and process asynchronously, then report completion by another means.
- Delivery is at least once. Retries mean your endpoint can see the same run more than once. Make handlers idempotent, keyed on the run ID sent in the request headers.
- Missed runs are not silently replayed forever. If a schedule is paused and resumed, Tend fires the next occurrence rather than back-filling every one you missed.
- Daylight saving time behaves per IANA rules. A schedule at 02:30 in
America/Denverskips the nonexistent hour on the spring transition and runs once on the fall transition. If exact wall-clock behavior matters, prefer a schedule in UTC and convert in your handler.
#A safe cutover plan
Do not delete the crontab entry on the same day you create the schedule. The safest path is a short overlap with a dry-run period, using the development environment first. A tnd_dev_ key creates schedules in a separate environment that cannot touch production data.
- Create the schedule with a
tnd_dev_key and point it at a staging endpoint. Confirm it fires at the expected local time. - Make the target endpoint idempotent, or add a guard so a second invocation within the same window is a no-op.
- Create the production schedule with a
tnd_live_key while the crontab entry is still active. Run both for at least one full cycle, comparing results. - Comment out the crontab entry, do not delete it. Keep it for a week, then remove it.
- Add an alert on failed runs before you walk away. See the monitoring guide for how.
# Trigger an immediate run to verify the endpoint before the first scheduled tick
curl -X POST https://api.tendcomputer.com/v2/schedules/sch_01J9C2VN5R8H3K7YQ4WTB6DXMA/trigger \
-H "Authorization: Bearer $TEND_API_KEY"
# Then inspect the resulting run
curl "https://api.tendcomputer.com/v2/runs?schedule_id=sch_01J9C2VN5R8H3K7YQ4WTB6DXMA&limit=1" \
-H "Authorization: Bearer $TEND_API_KEY"#Errors you may see during migration
| HTTP | Code | Likely cause |
|---|---|---|
| 400 | invalid_cron_expression | A six-field expression (with seconds), a Quartz-style ?, or a misspelled timezone. Use five fields and an IANA name like Europe/Berlin. |
| 409 | schedule_name_taken | A schedule with that name already exists in this environment. Update it instead of creating another. |
| 413 | payload_too_large | The payload exceeds 256 KB. Store the data elsewhere and send a reference. |
| 422 | interval_too_short | You translated a sleep 10 loop into a schedule. The minimum interval is 30 seconds. |