Matching Timezone and Locale Between Local and Prod
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.
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
- 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"]
- 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.
- Store instants, not local times. Use
timestamptzin 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';
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.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.
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
Keep the timezone matrix test in CI for date-heavy modules, so a new implicit dependency fails the pull request.
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.
Lint for naive timestamps —
timestamp without time zonein migrations,datetime.now()withouttzin Python — with a simple CI grep.
Platform caveats
macOS: the host's timezone does not propagate into Docker Desktop containers; containers default to UTC unless
TZis set, which is one reason host-run tests and container-run code disagree.
Alpine images:
TZnames other than UTC need thetzdatapackage; without it,TZ=Europe/Berlinsilently 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.