Run a cron job in your timezone, with daylight saving

You want a digest to go out at 09:00 in Paris. Most schedulers read a cron expression in UTC, so you write 0 7 * * *, because Paris is two hours ahead of UTC. It is, from the end of March to the end of October. For the other five months it is one hour ahead, and the same job lands at 08:00.

This page shows what goes wrong, which schedulers let you avoid it, and how to run the job at 09:00 Paris time all year with Clockwork. The requests below were run against Clockwork on 15 October 2026, and the outputs shown are what they printed that day.

What goes wrong in UTC

In 2026 the clocks in Paris go forward on 29 March (02:00 becomes 03:00) and back on 25 October (03:00 becomes 02:00). A job written once, in UTC, follows UTC and not Paris:

Written in UTCUntil 28 March29 March to 24 OctoberFrom 25 October
0 7 * * *08:00 in Paris09:00 in Paris08:00 in Paris
0 8 * * *09:00 in Paris10:00 in Paris09:00 in Paris

Either you edit the job twice a year, or the digest is an hour early or an hour late for part of it. The United States changes on other dates (8 March and 1 November in 2026), so a team working across the Atlantic sees the gap between the two cities shrink by an hour for a few weeks each spring and autumn as well.

The fix is not a better UTC expression. It is a scheduler that reads the expression in your zone.

Where your scheduler stands

What each platform's own documentation says, checked on 15 October 2026:

SchedulerTime zone of the cron expressionSource
GitHub Actions on.scheduleUTC, or an IANA zone set with timezone on each scheduleGitHub Docs
Vercel Cron Jobs"The timezone is always UTC"Vercel Docs
Cloudflare Workers Cron Triggers"Cron Triggers execute on UTC time"Cloudflare Docs
ClockworkUTC, or an IANA zone set with timezone on each jobAPI reference

If you are on GitHub Actions, the setting may be all you need: see GitHub Actions schedule in your local timezone. On Vercel and Cloudflare the expression is UTC with no option; see Vercel cron on the Hobby plan and Cloudflare Workers cron triggers for what you can do there.

Run it at 09:00 Paris time with Clockwork

A Clockwork job is an HTTP request on a schedule: at the time you set, Clockwork calls your URL. Add timezone with an IANA name and the five fields are read as the wall clock in that zone. You need an API key with the clockwork scope (Scheduled jobs on the Keys page); see API Keys.

curl -X POST https://api.vernesoft.com/v1/clockwork/jobs \
  -H "Authorization: Bearer $VERNE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Morning digest",
    "schedule": "0 9 * * 1-5",
    "timezone": "Europe/Paris",
    "url": "https://example.com/jobs/morning-digest",
    "method": "POST",
    "body": "{\"digest\": \"morning\"}"
  }'

The answer is the job, with the zone it is read in and its next run:

{
  "id": "6b0f6a8e-3c1d-4f7a-9e2b-5d8c0a1f4e97",
  "name": "Morning digest",
  "schedule": "0 9 * * 1-5",
  "timezone": "Europe/Paris",
  "url": "https://example.com/jobs/morning-digest",
  "method": "POST",
  "is_active": true,
  "next_run_at": "2026-10-16T07:00:00Z"
}

next_run_at is in UTC, like every timestamp the API returns. Created on Thursday 15 October, the job runs next on Friday 16 October at 07:00 UTC, which is 09:00 in Paris. After 25 October the same job runs at 08:00 UTC, and you do not touch it.

The Node.js and Python versions are further down; the SDKs do not have the timezone field yet, so they call the HTTP API directly.

See the clock change before it happens

You do not have to wait until 25 October to check what a job will do. Create a job pinned to one date, read its next_run_at, and delete it:

BASE=https://api.vernesoft.com/v1/clockwork
for schedule in "0 9 23 10 *" "0 9 26 10 *" "30 2 25 10 *" "30 2 28 3 *"; do
  job=$(curl -s -X POST "$BASE/jobs" \
    -H "Authorization: Bearer $VERNE_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg s "$schedule" \
      '{name: "DST check", schedule: $s, timezone: "Europe/Paris", url: "https://example.com/dst-check"}')")
  printf '%-14s -> %s\n' "$schedule" "$(jq -r .next_run_at <<<"$job")"
  curl -s -o /dev/null -X DELETE "$BASE/jobs/$(jq -r .id <<<"$job")" \
    -H "Authorization: Bearer $VERNE_API_KEY"
done

Run on 15 October 2026, it printed:

0 9 23 10 *    -> 2026-10-23T07:00:00Z
0 9 26 10 *    -> 2026-10-26T08:00:00Z
30 2 25 10 *   -> 2026-10-25T00:30:00Z
30 2 28 3 *    -> 2027-03-28T01:00:00Z
  • 23 and 26 October: 09:00 in Paris, before and after the change, is 07:00 and then 08:00 UTC.
  • 25 October, 02:30: that night 02:30 happens twice, first in summer time (00:30 UTC) and an hour later in winter time (01:30 UTC). The job runs once, at the first.
  • 28 March 2027, 02:30: that night 02:30 does not exist, because the clocks jump from 02:00 to 03:00. The job is not skipped: it runs once, at 03:00 Paris time, 01:00 UTC.

The two nights a year

A schedule that names a time of day, like 0 9 * * * or 30 2 * * *, runs once on both transition days: at the first occurrence of a repeated time, and at the end of the gap for a missing one. A schedule that repeats within the day, with * in the minute or hour field, follows the clock as it is: 0 * * * * runs 23 times on 29 March 2026 and 25 times on 25 October 2026. The rules, with a table for each case, are in the API reference under Timezones and daylight saving.

Two practical consequences:

  • A job that must run exactly once a day needs a time of day, not a *.
  • A nightly job is easier to reason about outside 02:00–03:00. In most European zones that is the hour that goes missing or happens twice. Run it at 01:00 or 04:00 and the question does not come up.

The same from Node.js and Python

The whole lifecycle in Node.js 18 or later, with fetch: create the job, read it back, move it to another zone, see a bad zone refused, delete it.

const BASE = 'https://api.vernesoft.com/v1/clockwork';
const headers = {
  Authorization: `Bearer ${process.env.VERNE_API_KEY}`,
  'Content-Type': 'application/json',
};

async function clockwork(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (res.status === 204) return null;
  const data = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
  return data;
}

// Weekdays at 09:00 in Paris, all year.
const job = await clockwork('POST', '/jobs', {
  name: 'Morning digest',
  schedule: '0 9 * * 1-5',
  timezone: 'Europe/Paris',
  url: 'https://example.com/jobs/morning-digest',
  method: 'POST',
  body: JSON.stringify({ digest: 'morning' }),
});
console.log('created', job.timezone, 'next run (UTC):', job.next_run_at);

// Read it back.
const stored = (await clockwork('GET', '/jobs')).find((j) => j.id === job.id);
console.log('stored ', stored.schedule, stored.timezone);

// Move it to 08:30 in New York. next_run_at is recomputed at once.
const moved = await clockwork('PATCH', `/jobs/${job.id}`, {
  schedule: '30 8 * * 1-5',
  timezone: 'America/New_York',
});
console.log('moved  ', moved.timezone, 'next run (UTC):', moved.next_run_at);

// An offset is not a zone: it is refused, and the job is left as it was.
try {
  await clockwork('PATCH', `/jobs/${job.id}`, { timezone: '+02:00' });
} catch (err) {
  console.log('refused', err.message);
}

await clockwork('DELETE', `/jobs/${job.id}`);
console.log('deleted');
VERNE_API_KEY=vrn_9f8a7... node morning-digest.mjs
created Europe/Paris next run (UTC): 2026-10-16T07:00:00Z
stored  0 9 * * 1-5 Europe/Paris
moved   America/New_York next run (UTC): 2026-10-16T12:30:00Z
refused 400 invalid_payload: Unknown timezone "+02:00". Use an IANA timezone name such as "Europe/Paris" or "America/New_York", or "UTC".
deleted

And in Python, with requests:

import os

import requests

BASE = "https://api.vernesoft.com/v1/clockwork"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['VERNE_API_KEY']}"

res = session.post(f"{BASE}/jobs", json={
    "name": "Morning digest",
    "schedule": "0 9 * * 1-5",
    "timezone": "Europe/Paris",
    "url": "https://example.com/jobs/morning-digest",
    "method": "POST",
    "body": '{"digest": "morning"}',
})
if not res.ok:
    error = res.json()["error"]
    raise SystemExit(f"{res.status_code} {error['code']}: {error['message']}")
job = res.json()
print("created", job["timezone"], "next run (UTC):", job["next_run_at"])

session.delete(f"{BASE}/jobs/{job['id']}").raise_for_status()
print("deleted")

What stays in UTC

Only the reading of schedule changes. next_run_at, last_run_at and the times in the run history are UTC, so compare them with UTC. A delayed job runs at an exact instant, run_at, and has no zone. A job created without timezone is a UTC job.

The Free plan allows 10 active recurring jobs; see Plan Limits.

Where to go next


Scheduled jobs are part of Nautilus by Verne Software, sign-in and user management for SaaS teams; the same scheduler runs its automatic deletion of inactive accounts. One plan, priced by active users, hosted in France. Create a free account or see the plans.