Tech Digest

Guide

Why is my Docker container in the wrong time zone even though I set TZ?

`TZ=Europe/Berlin` is only a name. The C library looks it up in `/usr/share/zoneinfo` inside the image, and when that file is missing it falls back to UTC without a word. We ran `date` with `TZ` set in 25 images to see which ones honour it, then tested the mounts, runtimes and databases that keep their own clock.

Last reviewed

Why does a Docker container ignore the TZ environment variable?

Because TZ names a zone file, and the image does not contain it. glibc and musl read /usr/share/zoneinfo/<name>, and the tzset(3) manual says that if the file cannot be read, UTC is used, with no error. Alpine, Ubuntu, node:alpine, Forgejo and Gitea images ship no zone files. Check with docker exec <name> ls /usr/share/zoneinfo/Europe/Berlin. If it is missing, mount /etc/localtime read only and remove TZ, or install tzdata in your own image.

Because TZ is only the name of a file, and the image you are running does not contain that file. When a program asks the C library for local time, glibc or musl opens /usr/share/zoneinfo/Europe/Berlin inside the container, not on the host. If the image was built without the tzdata package, there is nothing there, and the tzset(3) manual is blunt about what happens next: "If the specified file cannot be read or interpreted, Coordinated Universal Time (UTC) is used." No warning, no log line, no failed start.

Check it in one command:

bash
docker exec <name> ls /usr/share/zoneinfo/Europe/Berlin

If that says No such file or directory, TZ cannot work in that image, and no amount of quoting or restarting will change it.

Two answers circulate, and both are half right#

The first answer is "set TZ", repeated on nearly every page about the subject. It is the right fix for most images, and silently wrong for a few of the most common bases: alpine, ubuntu, node:alpine, and the official Forgejo and Gitea images. The people for whom it failed then go looking for a typo in their compose file.

The second answer is "mount the host's /etc/localtime and /etc/timezone". The /etc/localtime half works in every image we tried, with or without zone files. The /etc/timezone half does nothing for the C library, and on a host with no such file it creates a directory on the host instead. Combining both answers, TZ plus the mount, is the one setup we found that fails in a way neither fails alone.

Which images honour TZ, measured#

We ran TZ=Europe/Berlin date "+%H:%M %Z %z" in each image on Docker Engine Engine 28.1.1 on 26 October 2026 at 04:39 UTC, so the right answer was 05:39 CET +0100, and checked for the zone file.

ImageZone filesdate with TZ=Europe/Berlin
alpine:3.22none04:39 UTC +0000
ubuntu:24.04none04:39 Europe +0000
busybox:latest (glibc)none04:39 Europe +0000
node:22-alpinenone04:39 UTC +0000 (Node itself is right, see below)
codeberg.org/forgejo/forgejo:16noneUTC
gitea/gitea:1noneUTC
debian:bookworm-slim, debian:trixie-slimyes05:39 CET +0100
postgres:18-alpine, postgres:18yescorrect
vaultwarden/server:latest (1.37.4) and latest-alpineyescorrect
ghcr.io/immich-app/immich-server:release (v3.3.5)yescorrect
lscr.io/linuxserver/sonarr, lscr.io/linuxserver/jellyfinyescorrect
jellyfin/jellyfin:latestyescorrect
nginx:1.28-alpine, python:3.13-alpine, python:3.13-slimyescorrect
node:22-slim, eclipse-temurin:21-jre-alpineyescorrect
louislam/uptime-kuma:2, grafana/grafana, nextcloud, pihole/piholeyescorrect
ghcr.io/home-assistant/home-assistant:stableyescorrect
gcr.io/distroless/static-debian12, base-debian12yesno date; a Go binary printed CET

The pattern is in the Dockerfiles, not in the base distribution. Alpine ships no zone files, yet the Postgres Alpine Dockerfile adds tzdata and builds with --with-system-tzdata=/usr/share/zoneinfo, Vaultwarden's Alpine Dockerfile adds tzdata, and LinuxServer.io adds it in both its Alpine and its Ubuntu base images, which is why their Sonarr and Jellyfin work while plain ubuntu:24.04 does not. The Node Alpine Dockerfile installs only libstdc++ and libatomic at runtime, so anything in that image that calls the C library, date, cron and shell scripts included, is on UTC. Debian slim images keep the zone files, so every Debian-based app in the table works.

The output tells you which failure you have#

Look at the abbreviation. CET or CEST means it worked. UTC from an image with TZ set means a musl image without zone files. Europe +0000, or America +0000, means a glibc image without zone files: having failed to find the file, glibc read the value as a POSIX zone string, took the letters before the slash as the zone's name and used an offset of zero. A typo does the same thing. TZ=Europe/Berln in debian:bookworm-slim, which has every zone file, printed Europe +0000.

If date shows the right zone but your app does not, the app has its own setting, covered below. And if echo $TZ in the container is empty, the variable never arrived, which is a different trap: Why .env variables do not reach the container covers where Compose drops it.

Mounting /etc/localtime, and when it stops working#

Without TZ, both C libraries fall back to /etc/localtime, and the zone file mounted there is read directly, so no zone files are needed in the image:

yaml
services:
  app:
    volumes:
      - /etc/localtime:/etc/localtime:ro

In alpine:3.22, ubuntu:24.04 and Forgejo 16, all without zone files, that mount alone gave CET. It also has a cost the TZ route does not: the container follows the host, so a host on UTC gives you UTC, and the host's file must exist. Run ls -l /etc/localtime on the host first.

The trap is adding TZ on top. With TZ=Europe/Berlin set and the Berlin file correctly mounted at /etc/localtime, Alpine printed UTC +0000 and Ubuntu printed Europe +0000. TZ takes precedence over /etc/localtime, its lookup fails, and the fallback is UTC, not the file next to it. If you mount /etc/localtime into an image without zone files, remove TZ, or set it to :/etc/localtime, which tzset(3) allows: a value "can be one of two formats, either of which can be preceded by a colon". With TZ=:/etc/localtime, Ubuntu gave CET.

/etc/timezone does nothing for libc, and can create a folder#

Many compose examples, including the Forgejo one in our Forgejo vs Gitea comparison until today, carry a second line, /etc/timezone:/etc/timezone:ro. We mounted a file containing Europe/Berlin at /etc/timezone, with nothing else, into Debian, Alpine and Forgejo. All three printed UTC. Neither glibc nor musl reads that file. Some programs read it themselves; the C library does not.

On a host without that file, the line is worse than useless. We found no /etc/timezone in the fedora, archlinux, rockylinux:10 or debian:trixie images, while debian:bookworm-slim had one, so do not assume your host has it. The Compose reference says "the short syntax creates a directory at the source path on the host if it doesn't exist". We reproduced it with a missing source path. debian:bookworm-slim, which has its own /etc/timezone file, refused to start: Are you trying to mount a directory onto a file (or vice-versa)?. Alpine and the LinuxServer.io Sonarr image started with an empty directory at /etc/timezone. The host was left with an empty, root-owned directory where a file was expected, the same mechanism Why a container gets permission denied on a bind mount measured for config files. Delete the line.

Runtimes and apps that keep their own clock#

Node carries its own time zone data. In node:22-alpine (Node 22.23.4, process.versions.tz 2026e), TZ=Europe/Berlin made new Date().toString() print GMT+0100 (Central European Standard Time), while date in the same container printed UTC. So a Node app's own logs can be right while its cron jobs and shell scripts are an hour off. With only /etc/localtime mounted, Node got the offset right but named the zone Europe/Brussels.

Java reads TZ and has its own zone data too. In eclipse-temurin:21-jdk-alpine with the image's zone files deleted, TZ=Europe/Berlin still gave Europe/Berlin. The /etc/localtime mount is where Java goes wrong. Mounted from our Ubuntu host, it gave GMT. Mounted with a copy of the image's own Berlin file, it gave Arctic/Longyearbyen, a zone with the same rules. A symlink inside the container gave Europe/Berlin. For Java, set TZ, or -Duser.timezone=Europe/Berlin, which overrode TZ when passed through JAVA_TOOL_OPTIONS.

Go binaries in distroless images were fine, because distroless ships /usr/share/zoneinfo. The same static Go binary in Alpine, with no zone files, fell back to UTC.

Postgres decides once. The documentation says the built-in default is GMT, but "initdb will install a setting there corresponding to its system environment", meaning postgresql.conf. We initialized postgres:18-alpine (18.6) without TZ and got timezone = UTC and log_timezone = UTC in the file. Restarting the same data directory with TZ=Europe/Berlin changed date in the container to CET, and SHOW timezone; still said UTC. A fresh directory initialized with TZ set got 'Europe/Berlin'. On an existing database:

sql
ALTER SYSTEM SET timezone = 'Europe/Berlin';
ALTER SYSTEM SET log_timezone = 'Europe/Berlin';
SELECT pg_reload_conf();

This changes display and log timestamps, not stored data: the documentation says "all timezone-aware dates and times are stored internally in UTC" and converted to the TimeZone zone "before being displayed to the client". The same rule applies to every new data directory, including the one a major upgrade creates, so set TZ for the new cluster when following Upgrading Postgres to a new major version in Docker.

Apps that use TZ. Vaultwarden stamped its log lines 04:43 without TZ and 05:43 with it. Immich documents TZ for the server, where it "is used by exiftool as a fallback in case the timezone cannot be determined from the image metadata" and "for logfile timestamps and cron job execution", so a wrong zone there can shift photos with no zone in their metadata, not just logs. LinuxServer.io images such as Sonarr and Jellyfin take TZ like any other variable.

Fixes, in the order to try them#

1. Image has zone files: set TZ. Put it under environment:, which the Compose reference says overrides values from env_file, and keep it in the per-stack layout Docker Compose conventions sets out:

yaml
services:
  app:
    environment:
      TZ: Europe/Berlin

2. Image has none: mount /etc/localtime instead. Use the YAML above and no TZ. This is the fix for Forgejo, Gitea and anything built FROM alpine or FROM ubuntu without tzdata.

3. Your own image: install tzdata. RUN apk add --no-cache tzdata on Alpine, apt-get install -y --no-install-recommends tzdata on Debian or Ubuntu. Never install it into a running container: it is gone at the next recreate.

4. No zone files and no mount possible: use a POSIX string. TZ='CET-1CEST,M3.5.0,M10.5.0/3' carries the rules in the value itself. Alpine and Ubuntu, both without zone files, printed CET +0100 with it. You then own the daylight saving rules, which is why this is the last resort.

When this answer stops applying#

  • Docker Desktop, Podman, Kubernetes. We tested Linux Engine 28.1.1 with Compose 2.35.1 only. The lookup inside the image is the same anywhere; what the host offers to mount is not.
  • Other app settings. Home Assistant, Grafana, Nextcloud and many web apps also have a time zone in their own configuration or per user profile. We did not test how each combines with TZ.
  • Images change. The table is a snapshot of 26 October 2026 tags. Re-run the ls check after a base image change.

What to do next#

Run docker exec <name> date in every container that writes logs or runs schedules. Anything printing UTC or Europe +0000 gets either TZ removed and /etc/localtime mounted, or tzdata in its Dockerfile. Delete every /etc/timezone mount line while you are there. Then check Postgres separately with SHOW timezone;, because its answer was fixed on the day the data directory was created. If a log line still shows a gateway address instead of the client, that is a separate fix, covered in Why container logs show 172.17.0.1, not the client IP.

Questions#

Why does date show Europe instead of CET in my container?

Because the image has no zone files and uses glibc. When glibc cannot find /usr/share/zoneinfo/Europe/Berlin, it reads the value as a POSIX zone string, takes the letters Europe as the zone's abbreviation and applies an offset of zero. In our tests ubuntu:24.04 and busybox printed Europe +0000. musl-based images such as alpine print UTC +0000 instead. Either output means the zone file is missing, not that TZ failed to reach the container.

Should I mount /etc/localtime or set TZ?

Set TZ if the image has zone files, because it is one line, works on every host, and Java and Node read it directly. Mount /etc/localtime:/etc/localtime:ro if the image has none. Do not do both on an image without zone files: TZ takes precedence, its lookup fails, and the container stays on UTC with the correct file mounted right next to it. We saw that on Alpine and Ubuntu.

Does mounting /etc/timezone into a container do anything?

Not for the C library. In our tests a container with /etc/timezone containing Europe/Berlin and no other change still printed UTC on Debian, Alpine and Forgejo. Some programs read the file themselves, but the mount line copied from many compose examples fails on hosts that have no /etc/timezone (the Fedora, Arch, Rocky Linux 10 and Debian 13 images we checked had none): Docker creates an empty directory at that path on the host instead.

How do I add tzdata to an Alpine or Ubuntu image?

In your own Dockerfile, RUN apk add --no-cache tzdata on Alpine, or RUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf /var/lib/apt/lists/* on Debian and Ubuntu, then set ENV TZ=Europe/Berlin or pass it at run time. Do not install it in a running container from someone else's image: the change disappears the next time the container is recreated.

Why did changing TZ not change the time zone in Postgres?

Because Postgres stores its timezone and log_timezone settings in postgresql.conf, and initdb writes them from the environment once, when the data directory is created. A database first started without TZ says UTC forever, whatever you set later. Change it with ALTER SYSTEM SET timezone = 'Europe/Berlin'; and ALTER SYSTEM SET log_timezone = 'Europe/Berlin';, then SELECT pg_reload_conf();.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.