Skip to content
MigrationIntermediate15 min

Migrate from cron jobs

Move a crontab to Halyard schedules one entry at a time, with retries, logs and time zones you can see, and no night without a run.

Tomás ReyesStaff engineer, Platform · Updated October 6, 2026
  • Node.js
  • Python
  • cURL
On this page

Take inventory

Start by writing down what the crontab actually does. Here is the one we'll migrate: four entries on a single server whose clock is set to America/Chicago, which nobody remembered until this exercise.

crontab -l
# m  h  dom mon dow  command*/15 *  *   *   *    /usr/bin/node /srv/app/scripts/sync-inventory.js0    3  *   *   *    /usr/bin/python3 /srv/app/scripts/cleanup_sessions.py30   6  *   *   1    /usr/bin/node /srv/app/scripts/weekly-digest.js0    0  1   *   *    /usr/bin/python3 /srv/app/scripts/close_invoices.py >> /var/log/invoices.log 2>&1

Give every line a task name, a queue and an explicit time zone. Fast, frequent work and slow monthly work belong on different queues.

CronTaskQueueTime zoneLanguage
*/15 * * * *sync-inventorymaintenanceUTCNode.js
0 3 * * *cleanup-sessionsmaintenanceUTCPython
30 6 * * 1weekly-digestemailsAmerica/ChicagoNode.js
0 0 1 * *close-invoicesbillingAmerica/ChicagoPython

Migrate step by step

  1. Turn each script into a task

    Don't rewrite the scripts. Move their top-level code into a function, accept a log callback, and register that function as a task. The cron entry can keep calling the script directly until you switch.

    // scripts/sync-inventory.js (before: ran top to bottom, then exited)export async function syncInventory({ log = console.log } = {}) {  const changed = await pullSupplierFeed();  await applyStockChanges(changed);  log(`Synced ${changed.length} SKUs`);  return { changed: changed.length };} // worker.jsimport { Worker } from "@halyard/node";import { syncInventory } from "./scripts/sync-inventory.js";import { weeklyDigest } from "./scripts/weekly-digest.js"; const worker = new Worker(process.env.HALYARD_KEY, { queues: ["maintenance", "emails"] }); worker.task("sync-inventory", (job) => syncInventory({ log: (m) => job.log(m) }));worker.task("weekly-digest", (job) => weeklyDigest({ log: (m) => job.log(m) })); worker.start();
  2. Create the schedules, paused

    Create all four schedules in one go with paused: true. They exist, show up in the dashboard and can be reviewed, but won't fire until you resume them, so cron and Halyard never both run the same work.

    // scripts/create-schedules.jsimport { Halyard } from "@halyard/node"; const halyard = new Halyard(process.env.HALYARD_KEY); const schedules = [  { cron: "*/15 * * * *", task: "sync-inventory", queue: "maintenance", timezone: "UTC" },  { cron: "0 3 * * *", task: "cleanup-sessions", queue: "maintenance", timezone: "UTC" },  { cron: "30 6 * * 1", task: "weekly-digest", queue: "emails", timezone: "America/Chicago" },  { cron: "0 0 1 * *", task: "close-invoices", queue: "billing", timezone: "America/Chicago" },]; for (const s of schedules) {  const created = await halyard.schedules.create({ ...s, overlap: "skip", paused: true });  console.log(created.id, s.task);}
  3. Switch over one line at a time

    Start with the most frequent, least risky entry. Comment it out in the crontab, resume its schedule and watch one run succeed. Repeat for the next line the following day.

    Terminal
    # 1. Comment out the line in the crontabcrontab -l | sed 's|^\*/15 .*sync-inventory.js|# moved to Halyard: &|' | crontab - # 2. Resume the matching schedulehalyard schedules resume sch_2Qm7Tz4Lw9 # 3. Watch the first runhalyard jobs list --task sync-inventory --limit 1 --follow

    Prefer the API? Resuming is a single request:

    Terminal
    curl -X POST https://api.halyard.example/v3/schedules/sch_2Qm7Tz4Lw9/resume \  -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d"
  4. Retire the cron server

    Once the last schedule has run through a full cycle (a month, if you have a monthly job), delete the crontab and the wrapper scripts' main entry points. Keep a copy of the old crontab in your repository's history for the record.

What behaves differently

  • Failures retry. A cron job that fails waits for its next slot. A Halyard job retries with backoff, so make sure each task is safe to run twice (see Make jobs idempotent).
  • Output goes to job logs, not to local mail or /var/log. Anything passed to job.log is kept for 30 days and searchable in the dashboard.
  • No overlapping runs. With overlap: "skip", a 15-minute sync that takes 20 minutes skips a slot instead of running twice in parallel, which cron would happily do.
  • Time zones are explicit. Each schedule names its zone; nothing depends on a server's clock setting.

Day-of-month and day-of-week

Halyard follows the same rule as classic cron: when both the day-of-month and the day-of-week fields are restricted, a run happens when either matches. 0 9 1 * 1 runs on the 1st of every month and every Monday, not only on Mondays that fall on the 1st. Expressions copied straight from your crontab keep their meaning.

Recap

  • Inventory first: every crontab line gets a task name, a queue and a time zone.
  • Scripts become tasks by wrapping their main function, not by rewriting them.
  • Schedules are created paused and resumed one at a time as cron lines are commented out.
  • Cron's server-local time becomes an explicit time zone on each schedule.

Next steps

Keep going with a related guide, or look up every option used here in the Jobs API reference.

Suggested