Your Bot Posts at the Wrong Time — Containers Run on UTC

·5 min read·Updated
timezoneschedulingbotspythoncontainers

If your bot's daily message lands a few hours off, the cause is almost always the same: your container runs on UTC. Nothing sets a timezone for it, so anything your code calls "local time" is UTC, and a job written for 9am fires at 9am UTC. The quick fix is a TZ environment variable — but for a bot it's usually the wrong fix, because a bot's users aren't all in one timezone and a fixed local time breaks twice a year at daylight-saving boundaries. Store and schedule in UTC, convert to a specific named zone only when you display or compute a target time.

Your clock isn't wrong — your timezone is

Worth separating, because people debug the wrong thing. Containers share the host's kernel clock, so the instant your bot sees is correct — time.time() and Date.now() are right to the second.

What's missing is the local-time interpretation. With no TZ set, the C library falls back to UTC, so datetime.now(), new Date().getHours(), and every "local" formatter answer in UTC. If you're in UTC+2, your 9am job runs at 11am your time, consistently, forever.

Consistently is the useful word. A steady offset means a timezone problem. A drifting or random one means something else.

The one-line fix, and when it actually works

Set a TZ environment variable on your app:

TZ=Europe/Kyiv

Two conditions have to hold.

Your image needs the tz database. TZ is a lookup key into a set of files that not every image ships. Debian-based images generally have it, Alpine images generally don't — and the failure is silent, which is what makes it confusing. If you build from Alpine, add it:

RUN apk add --no-cache tzdata

If you deployed by uploading a zip, the image built for you is Debian-based, so TZ works without extra steps.

Set it before the process starts, not from inside your code. Assigning to the environment after your runtime has already read the timezone is unreliable — some runtimes cache it at startup. An app-level environment variable is applied at container creation, which is exactly what you want.

One piece of advice you'll find everywhere that doesn't apply here: mounting the host's /etc/localtime into the container. You can't bind-mount host paths on a managed platform. Use TZ.

Why TZ is the wrong fix for a bot

For a personal script, setting TZ and moving on is fine. For a bot with users, it papers over the real question: whose 9am?

  • Your users aren't in your timezone. A reminder bot with users on three continents has no single correct local time. The zone has to be a per-user or per-guild setting, not a process-wide one.
  • Daylight saving moves the target. If you schedule against a fixed offset, your 9am becomes 8am or 10am on a Sunday in spring and again in autumn. This is the classic bug that ships in March and gets reported in November.
  • Process-wide state is easy to break. One TZ value quietly affects every date operation in your app, including logs and database writes, which makes debugging harder later.

The durable shape is the one most scheduling libraries assume anyway:

  1. Store instants in UTC. Database columns, cached "last run" markers, everything.
  2. Store zones as IANA names — Europe/Kyiv, America/New_York — never as offsets like +02:00. A name knows about DST; an offset doesn't.
  3. Convert at the edges, when computing the next run for a target zone or formatting a timestamp for a user.

Python: zoneinfo needs data too

Python's standard library handles this well, with one deployment gotcha. Per the Python docs: "By default, zoneinfo uses the system's time zone data if available; if no system time zone data is available, the library will fall back to using the first-party tzdata package available on PyPI."

If neither exists, you get ZoneInfoNotFoundError — a KeyError subclass, so a bare except KeyError will swallow it.

The safe move is to declare it as a dependency rather than rely on the base image:

tzdata

in requirements.txt. Then your code doesn't care what image it lands on:

from datetime import datetime
from zoneinfo import ZoneInfo

now_utc = datetime.now(ZoneInfo("UTC"))
local = now_utc.astimezone(ZoneInfo("Europe/Kyiv"))

Note both are timezone-aware. Mixing aware and naive datetimes raises TypeError on comparison, which is a good thing — it catches the bug at the point of confusion rather than three hours later.

JavaScript and everything else

Don't rely on the process timezone. Ask for the zone explicitly:

new Intl.DateTimeFormat("en-GB", {
  timeZone: "Europe/Kyiv",
  dateStyle: "short",
  timeStyle: "short",
}).format(new Date());

This is correct regardless of what TZ is set to, which is the property you want in a container. The same principle holds elsewhere: pass the zone as an argument rather than depending on ambient state.

Restarts are part of the schedule problem

One more thing specific to running on a container platform. If your scheduler lives in memory and computes "next run" at startup, a redeploy or restart resets it — and depending on when it lands, your job can fire twice or not at all.

Persist the last completed run, not the next scheduled one, and on startup decide whether the job is due. That makes restarts harmless instead of a source of duplicate messages. More on the pattern in scheduled tasks without cron.

FAQ

Why is my bot's scheduled message a few hours off?

The container runs on UTC because nothing sets a timezone. Anything your code treats as local time is UTC, so the offset is constant.

How do I set the timezone for my container?

Add a TZ environment variable, such as TZ=Europe/Kyiv. The image also needs the tz database — Alpine images usually need apk add tzdata added to the Dockerfile.

Why does setting TZ do nothing?

Almost always a missing tz database in the image. It fails silently and leaves you on UTC.

Is the container's clock wrong?

No. The instant is correct; only the local-time interpretation defaults to UTC.

Should I set TZ for a bot with users in different countries?

No. Store instants in UTC, store each user's or guild's zone as an IANA name, and convert when you compute or display.

Why did my bot shift by an hour in the spring?

You scheduled against a fixed UTC offset instead of a named zone. Offsets don't know about daylight saving; zone names do.

Wrapping up

UTC-by-default isn't a quirk to work around — it's the only sane default for a machine that doesn't know where its users are. TZ is a legitimate shortcut for a single-user script, and it needs the tz database present to do anything at all. But for a bot that schedules things for other people, the fix that keeps working is to treat UTC as the storage format and timezone as data: named zones, converted at the boundary, with the last run persisted so a restart doesn't shift anything.