A date test passes for developers in Berlin and fails in CI with expected '2026-09-18' but received '2026-09-17'; a report sorts Ärzte before Apotheke on one machine and after Zahnärzte on another; and a colleague in Singapore reports that the "today's orders" page is empty every morning. All three come from the same source: the process's timezone and locale are inherited from wherever it happens to run, and they differ between laptops, CI runners and production containers. This page makes them explicit and identical everywhere, as part of runtime parity frameworks.

The goal is not to pick a particular timezone for developers to live in, but to make the application's behaviour independent of the machine it runs on.

Diagnostic

Print the effective timezone and locale in each environment the code runs in:

#!/usr/bin/env bash
set -euo pipefail
echo "host:      TZ=${TZ:-unset} $(date +%Z) LANG=${LANG:-unset}"
docker compose exec -T api sh -c 'echo "container: TZ=${TZ:-unset} $(date +%Z) LANG=${LANG:-unset}"; node -e "console.log(Intl.DateTimeFormat().resolvedOptions().timeZone, Intl.Collator().resolvedOptions().locale)"'
docker compose exec -T db psql -U postgres -Atc "show timezone; show lc_collate;"
gh run view --log "$(gh run list --limit 1 --json databaseId -q '.[0].databaseId')" 2>/dev/null | grep -m2 -E 'TZ=|LANG=' || echo "check CI runner: TZ usually UTC, LANG C.UTF-8"

Expected bad output:

host:      TZ=unset CEST LANG=de_DE.UTF-8
container: TZ=unset UTC LANG=unset
UTC en-US
Europe/Berlin
en_US.utf8
check CI runner: TZ usually UTC, LANG C.UTF-8

The host runs in Berlin time with a German locale, the API container in UTC with no locale, the database in Berlin time, and CI in UTC with C.UTF-8. Four environments, three different combinations.

Timezone and Locale by Environment Table of timezone and locale values found on the host, API container, database and CI runner. Timezone and Locale by Environment Environment Timezone Locale developer host Europe/Berlin de_DE API container UTC unset, C database Europe/Berlin en_US CI runner UTC C.UTF-8
Every environment inherited its own defaults; none were set on purpose.

Root cause

Operating systems give every process a timezone and a locale from the environment (TZ, LANG, LC_*) or from system files, and most runtimes read them implicitly. Date libraries convert "now" and naive timestamps using the process timezone; string comparison, sorting and number formatting use the locale's collation and formatting rules. When nothing sets these explicitly, each environment falls back to its own defaults: laptops use the user's settings, Docker images default to UTC and the C/POSIX locale, managed databases use whatever the provider or image configured, and CI runners are usually UTC. Code that calls new Date().toDateString(), stores timestamp without time zone, or sorts with localeCompare without an explicit locale then produces different results depending on where it runs — and tests that pass at 10 a.m. fail at 11 p.m. when the local date crosses midnight in UTC.

The database is the easiest layer to overlook. Postgres converts timestamptz values to the session's timezone setting on output, and interprets naive timestamp literals in that zone on input. If the database container runs in Berlin time while the application writes UTC strings without offsets, every stored value is silently shifted by one or two hours depending on the season, and the error only shows when someone compares database output with application logs. Setting the server's timezone explicitly, and using offset-bearing literals from the application, removes the ambiguity at both ends.

Locale bugs are rarer but harder to spot, because they rarely produce errors. A list sorted differently, a number formatted with a comma instead of a dot, or a case-insensitive comparison that treats I and ı differently under a Turkish locale all look like data problems. Making every locale-sensitive call explicit is the only way to make them reproducible.

Resolution

  1. Set timezone and locale explicitly in every container through the shared Compose configuration, matching production (usually UTC and a UTF-8 locale):
x-runtime-env: &runtime-env
  TZ: UTC
  LANG: C.UTF-8
  LC_ALL: C.UTF-8

services:
  api:
    environment:
      <<: *runtime-env
  worker:
    environment:
      <<: *runtime-env
  db:
    image: postgres:16.4
    environment:
      <<: *runtime-env
      PGTZ: UTC
    command: ["postgres", "-c", "timezone=UTC", "-c", "log_timezone=UTC"]
  1. Pin the test runner's timezone and locale, including when tests run on the host outside containers:
{
  "scripts": {
    "test": "TZ=UTC LANG=C.UTF-8 vitest run"
  }
}

For Python, set TZ=UTC in pytest.ini via env (with pytest-env) or in the task runner target; for the JVM, pass -Duser.timezone=UTC -Duser.language=en -Duser.country=US.

  1. Store instants, not local times. Use timestamptz in Postgres and ISO-8601 strings with offsets in APIs; convert to a user's timezone only at the presentation edge, using the user's explicit preference rather than the server's:
ALTER TABLE orders ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';
SELECT count(*) FROM orders WHERE created_at >= date_trunc('day', now() AT TIME ZONE 'Asia/Singapore') AT TIME ZONE 'Asia/Singapore';
  1. Make locale-sensitive operations explicit in code — Intl.Collator('de-DE'), toLocaleDateString('en-GB', { timeZone: 'UTC' }) — so results do not depend on the process locale.

  2. Add a test that runs under a hostile timezone to catch implicit dependencies:

#!/usr/bin/env bash
set -euo pipefail
for tz in UTC Pacific/Kiritimati America/Adak; do
  echo "== TZ=$tz"
  TZ="$tz" LANG=C.UTF-8 npx vitest run --reporter dot tests/dates
done

Pacific/Kiritimati (UTC+14) and America/Adak (UTC−10, with DST) put "today" on different dates from UTC for much of the day, which flushes out code that assumes local and UTC dates agree.

Why a Date Test Fails Only in the Evening Timeline of one calendar evening showing local Berlin date and UTC date diverging. Why a Date Test Fails Only in the Evening 21:00 Berlin both dates 17 Sep 23:59 Berlin both 17 Sep 00:00 Berlin local 18, UTC 17 01:59 Berlin local 18, UTC 17 02:00 Berlin both 18 Sep
Between midnight UTC+2 and midnight UTC, local and UTC dates differ, so implicit conversions break.

Expected output

container: TZ=UTC UTC LANG=C.UTF-8
UTC en-US
UTC
C.UTF-8
== TZ=UTC
 ✓ tests/dates (14 tests)
== TZ=Pacific/Kiritimati
 ✓ tests/dates (14 tests)
== TZ=America/Adak
 ✓ tests/dates (14 tests)

Every environment reports the same timezone and locale, and the date tests pass under three very different process timezones, proving the code no longer depends on them.

The Singapore "empty page" bug is fixed by the explicit user-timezone query rather than by the environment settings: the server now computes "today" in the viewer's timezone deliberately, instead of accidentally in the server's. That distinction — server runs in UTC, presentation uses the user's zone explicitly — is the rule that keeps these bugs from coming back.

Prevention

  1. Keep the timezone matrix test in CI for date-heavy modules, so a new implicit dependency fails the pull request.

  2. Add TZ and LANG to the parity check that compares local and production runtime settings, as in automating runtime parity checks between local and staging.

  3. Lint for naive timestampstimestamp without time zone in migrations, datetime.now() without tz in Python — with a simple CI grep.

Implicit vs Explicit Time and Locale Comparison of relying on inherited timezone and locale against setting them explicitly. Implicit vs Explicit Time and Locale inherited from machine set in Compose and tests differs by developer same everywhere naive timestamps timestamptz, ISO offsets sort order varies explicit Collator locale fails after midnight UTC matrix-tested
Explicit settings make behaviour identical on laptops, CI and production.

Platform caveats

macOS: the host's timezone does not propagate into Docker Desktop containers; containers default to UTC unless TZ is set, which is one reason host-run tests and container-run code disagree.

Alpine images: TZ names other than UTC need the tzdata package; without it, TZ=Europe/Berlin silently falls back to UTC.

Windows: Windows timezone IDs differ from IANA names; runtimes such as .NET and Java map them, but set IANA names in containers and CI to stay consistent.

Rollback

Remove the environment anchor and runner settings; processes return to inherited defaults:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml package.json
docker compose up -d --force-recreate

Frequently Asked Questions

Why do date tests fail only in the evening or only in CI?

They depend on the process timezone. When the local date and the UTC date differ — late evening in Europe, all afternoon in Asia-Pacific — code that mixes local and UTC dates produces off-by-one-day results. Pin TZ and store timezone-aware instants.

Should servers run in UTC?

Yes, as a convention: it removes daylight-saving ambiguity and makes logs comparable. Convert to users' timezones explicitly at the presentation layer.

Why does sorting differ between machines?

String collation follows the process locale unless specified. localeCompare without a locale uses the environment's, so Ä sorts differently under de_DE, en_US and C. Pass an explicit locale to collators.

Does setting TZ in Docker affect the host?

No. TZ affects only processes in that container. The host and other containers keep their own settings.