← Back to Blog

Why Our Scheduled Jobs Ran at the Wrong Time After Deploying to Azure — A Cron Expression Postmortem

Our report generation job was supposed to run at 9 AM local time before the business day started. In production on Azure, it ran at 2:30 PM. Here is exactly why, and how to write cron schedules that behave correctly in cloud environments.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 17 Jul 2026· Last reviewed Jul 2026· 8 min read · About the author →
Our Mumbai-based team learned about UTC the hard way at 9:00 AM IST, when the report that was supposed to be ready wasn't. The Azure VM was right on time. We were just asking it to run at the wrong time.
Key takeaways
  • Azure-hosted .NET services run in UTC regardless of the region you deploy to
  • A cron expression like 0 9 * * * means 9 AM UTC, not 9 AM local time
  • IST is UTC+5:30 — a 9 AM IST job must be scheduled as 0 3:30 UTC (0 3 * * * with a caveat)
  • Hangfire and Quartz.NET both default to UTC for cron schedules
  • TimeZoneInfo.FindSystemTimeZoneById lets you convert between IANA and Windows timezone IDs in .NET
Table of Contents

The incident

We were building a daily report generation job for a logistics client in Bangalore. The requirement was simple: generate the previous day's shipment summary every morning before 9 AM IST (Indian Standard Time) so the operations team had it waiting in their inbox when they started work. We wrote a Hangfire recurring job, set it to 0 9 * * * (9 AM, every day), and deployed to Azure App Service in the Southeast Asia region.

On the first morning in production, the report wasn't in anyone's inbox at 9 AM. It arrived at 2:30 PM. We checked the Hangfire dashboard — the job had executed successfully at the scheduled time. The cron expression said 9 AM. The job ran at 9 AM. The job ran at 9 AM UTC. In IST (UTC+5:30), that's 2:30 PM. The Azure VM had no idea we meant 9 AM Bangalore time.

Why Azure doesn't know your local time

Cloud providers run their virtual machines in UTC universally. It doesn't matter whether you deploy to East US, West Europe, or Southeast Asia — your .NET process reads DateTimeOffset.UtcNow and gets UTC. DateTime.Now returns UTC on Azure App Service and Azure Functions because the OS timezone is set to UTC. This is intentional: UTC avoids daylight saving time transitions causing jobs to run twice or not at all, and it makes logs from multi-region deployments comparable without timezone conversion.

Hangfire's recurring job scheduler stores next execution times in UTC and compares them against UTC. When you write RecurringJob.AddOrUpdate("daily-report", () => GenerateReport(), "0 9 * * *"), Hangfire interprets that cron expression in UTC. The job runs when the UTC clock reaches 09:00, regardless of where your team or your users are located.

The UTC offset calculation

Converting a local cron schedule to UTC requires knowing the UTC offset for the timezone, and being aware of DST transitions if applicable. IST (Indian Standard Time) is UTC+5:30 — a half-hour offset, which makes it slightly unusual compared to whole-hour offsets.

  • 9:00 AM IST = 9:00 - 5:30 = 3:30 AM UTC
  • A cron expression for 3:30 AM UTC is 30 3 * * *

Note that IST does not observe daylight saving time, so this offset is constant throughout the year. For timezones that do observe DST (US Eastern, UK, most of Europe), you need to check whether your desired local time falls during standard time or daylight time, since the UTC offset changes by one hour.

The fix in Hangfire

// Wrong: runs at 9 AM UTC, not 9 AM IST
RecurringJob.AddOrUpdate(
    "daily-shipment-report",
    () => _reportService.GenerateShipmentSummaryAsync(),
    "0 9 * * *"  // 9 AM UTC = 2:30 PM IST
);

// Correct approach 1: Use UTC-adjusted cron expression
RecurringJob.AddOrUpdate(
    "daily-shipment-report",
    () => _reportService.GenerateShipmentSummaryAsync(),
    "30 3 * * *"  // 3:30 AM UTC = 9:00 AM IST
);

// Correct approach 2: Specify timezone explicitly (Hangfire 1.7+)
// This is better because it handles DST transitions automatically
RecurringJob.AddOrUpdate(
    "daily-shipment-report",
    () => _reportService.GenerateShipmentSummaryAsync(),
    "0 9 * * *",  // 9 AM in the specified timezone
    TimeZoneInfo.FindSystemTimeZoneById("India Standard Time") // Windows timezone ID
);

// IANA timezone IDs on Linux (Azure App Service Linux):
// TimeZoneInfo.FindSystemTimeZoneById("Asia/Kolkata")

// Cross-platform timezone handling using TimeZoneConverter NuGet package
// This maps IANA IDs to Windows IDs and vice versa:
// Install-Package TimeZoneConverter
var indianTime = TZConvert.GetTimeZoneInfo("Asia/Kolkata");
RecurringJob.AddOrUpdate(
    "daily-shipment-report",
    () => _reportService.GenerateShipmentSummaryAsync(),
    "0 9 * * *",
    indianTime
);

The timezone-aware approach (Approach 2) is significantly better than manually adjusting the UTC offset, because it handles cases where the local timezone observes daylight saving time. If your servers move from one timezone to another (e.g., a DR failover between regions), or if your business logic involves users in different timezones, explicit timezone declarations make intent unambiguous.

The same problem in Quartz.NET

// Quartz.NET also defaults to UTC for cron triggers
// To specify a timezone, use TimeZoneInfo in the CronTrigger builder:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("daily-report-trigger")
    .WithCronSchedule(
        "0 0 9 * * ?",  // 9 AM — will be interpreted in the specified timezone
        x => x.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("India Standard Time"))
    )
    .Build();

Azure Functions timer triggers (cron) and UTC

// Azure Functions timer triggers also use UTC by default
// In your Function definition:

[FunctionName("DailyReport")]
public async Task Run(
    [TimerTrigger("30 3 * * *")] TimerInfo timer, // 3:30 AM UTC = 9:00 AM IST
    ILogger log)
{
    log.LogInformation("Daily report triggered at {UtcNow} UTC", DateTime.UtcNow);
    await _reportService.GenerateShipmentSummaryAsync();
}

// Azure Functions also support timezone specification via WEBSITE_TIME_ZONE
// app setting on Windows plans, or TZ environment variable on Linux plans.
// When set, timer triggers interpret cron expressions in that timezone.
// However, relying on an app setting for scheduling logic is fragile —
// prefer explicit UTC cron expressions or timezone parameters in code.

How to verify your cron expression before deploying

Before deploying any cron-scheduled job to a cloud environment, verify what time it will actually run by checking the next execution times in UTC. The Cron Expression Generator shows you the next 10 execution times for any cron expression — paste your expression, verify the UTC times are what you intend, then translate to local time to confirm it matches your requirement.

For the IST case: if the generator shows the next executions at 03:30 UTC, 03:30 UTC, 03:30 UTC... you know it will run at 9:00 AM IST every day. If it shows 09:00 UTC, you know it will run at 2:30 PM IST.

DST-aware scheduling for European and US timezones

For teams deploying jobs that target European or North American business hours, DST adds an extra complexity. A job scheduled to run at 9 AM CET (Central European Time, UTC+1) needs to run at 8 AM UTC in winter and at 7 AM UTC in summer (CEST is UTC+2). If you hardcode 0 8 * * *, the job runs at 9 AM in winter and 10 AM in summer — one hour off during summer time.

The correct solution is always the same: use timezone-aware scheduling APIs rather than hardcoded UTC offsets. Hangfire's TimeZoneInfo parameter, Quartz's .InTimeZone(), or ASP.NET Core's IHostedService combined with TimeZoneInfo.ConvertTime() all handle DST transitions automatically.

The generalizable lesson

Cloud servers live in UTC. Your users, your business hours, and your support staff may not. This is not an Azure-specific problem — AWS, GCP, and any Linux server you spin up will also run in UTC. Write all cron schedules in terms of UTC, or use timezone-aware scheduling APIs with explicit timezone declarations. Never assume that "9 AM" in a cron expression means 9 AM in any timezone other than UTC, and never rely on a server-side timezone setting for schedule correctness — that setting can change without notice and doesn't version-control cleanly alongside your code.

Try the free tool
Cron Expression Generator

Build and validate cron expressions visually. Understand what a schedule means and preview the next 10 execution times — essential before deploying a cron-based job to a cloud environment.

Open Cron Expression Generator