Docker Compose TZ: fix container time
Set TZ in a Compose service, find valid zone names with timedatectl, and understand why container logs show UTC while the host clock is right.
Where Docker Compose TZ values come from
A Docker Compose TZ value is a tzdata zone name, written as Area/Location: Europe/Berlin, America/New_York, Asia/Kolkata, Etc/UTC. Docker does not define that list. tzdata is the IANA time zone database, and the names are the file paths under /usr/share/zoneinfo inside the container. You set one as an environment variable on the service, then recreate the container, and processes inside it start formatting time in that zone.
services:
app:
image: ghcr.io/example/app:1.4
environment:
TZ: Europe/Berlin
restart: unless-stoppedThat is the whole answer for an image that ships tzdata and reads TZ. The rest of this guide covers the cases where the same two lines do nothing: an image with no zone files in it, a scheduler or database that keeps its own separate zone setting, and the /etc/localtime bind mount that old answers still recommend.
List the valid zone names
On a host with systemd, ask the host. These commands are safe to run on any VPS and they change nothing.
timedatectl list-timezones
timedatectl list-timezones | grep -i kolkata
timedatectl show -p Timezone --valueThe full list runs to several hundred entries, so pipe it through grep for the city or the region you want. On a host without systemd, or inside a container, the same names are just files:
ls /usr/share/zoneinfo
ls /usr/share/zoneinfo/EuropeThat directory also holds things that are not zones, such as zone.tab, leapseconds, posix and right, so timedatectl list-timezones is the cleaner list when you have it. Two habits to avoid. Legacy names like US/Eastern and Asia/Calcutta still resolve, because tzdata keeps them as backward-compatibility links, but the current names are America/New_York and Asia/Kolkata. Three or four letter abbreviations such as IST or PST are ambiguous between countries, and most of them are not zone names at all, so a typo there silently leaves you in UTC.
The container clock is never wrong
A container does not have its own clock. It shares the host kernel, so a call to clock_gettime(CLOCK_REALTIME) returns the same count of seconds since 1970 inside the container as it does on the host. Linux gained a time namespace in 5.6, but it offsets only the monotonic and boot-time clocks, and Docker does not expose it. There is no per-container wall clock to set.
So TZ changes nothing about that number. It changes how one process turns that number into text. This is why date in one container can say 14:05 while date in another says 16:05, on the same host, at the same instant. Both are right. Each one read a different zone file while formatting the same timestamp.
It is also why you cannot fix time inside a container. timedatectl set-time and ntpdate need CAP_SYS_TIME, which Docker drops by default, and with that capability granted you would be setting the host's clock for every other container on the box. If the wall time itself is wrong, and not only its presentation, the problem is on the host: a VPS whose clock drifts needs a working time sync daemon, not a Compose setting.
Case 1: images that honour TZ already
Images built on Debian, Ubuntu, Rocky or Alma base layers usually carry tzdata, and anything linked against glibc reads TZ the first time it formats a local time. When TZ is unset, glibc reads /etc/localtime instead. When neither is usable, it uses UTC. That fallback is the default you see in a fresh container.
Check your own service rather than trusting the image description:
docker compose exec app printenv TZ
docker compose exec app dateprintenv TZ proves the variable reached the container. date proves the zone files were found, because it prints the offset and the zone abbreviation that go with the zone you asked for. If printenv shows your zone and date still shows UTC, you are in case 2.
Case 2: images that need tzdata installed first
Alpine images, scratch images and statically linked binaries often ship no zone files at all. musl, the C library Alpine uses, accepts a zone name in TZ the same way glibc does, but it still has to read /usr/share/zoneinfo/Europe/Berlin to know what that name means. If the file is not there, the process formats in UTC. There is no error and no log line, which is why this case costs people an afternoon.
The fix belongs in the image, not in the running container:
FROM alpine:3.21
RUN apk add --no-cache tzdata
ENV TZ=Europe/BerlinOn a Debian or Ubuntu base the equivalent is apt-get install -y --no-install-recommends tzdata, followed by rm -rf /var/lib/apt/lists/* in the same layer. A Go program is a separate story, because Go does not use libc for zones: it reads the system files, and falls back to UTC when they are missing, unless the program imports time/tzdata, which embeds a copy of the database in the binary. Java reads the same system files through its own loader.
Do not install the package by hand with docker compose exec. The change lives in the container's writable layer, and the next docker compose up -d replaces that container with a fresh copy of the image, so the zone quietly disappears again. Either build the package into the image, or choose a tag that has it.
Case 3: the /etc/localtime bind mount, and what it breaks
Search for this problem and you will find this pair of volume lines:
services:
app:
image: ghcr.io/example/app:1.4
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:roIt often appears to work, because both glibc and musl fall back to /etc/localtime when TZ is unset. It also breaks in four ways that are hard to trace back to a volume line.
First, /etc/timezone does not exist on Rocky, Alma, Fedora or Arch hosts. When the source path of a bind mount is missing, Docker creates it rather than failing, as an empty directory, on the host and in the container. Any tool inside the image that reads /etc/timezone as a file then gets a directory, and configuration steps such as dpkg-reconfigure tzdata fail on it.
Second, the mount is read only, and a mount point cannot be replaced from inside the container anyway. If the image's own entrypoint sets the zone by writing /etc/localtime, which several self-hosting images do from a TZ variable, that write fails with Read-only file system or Device or resource busy.
Third, every container on the box now shares one zone, and that zone is the host's. You give up the arrangement most people actually want: UTC everywhere for storage and logs, with one local zone in the single service that has to show times to a person.
Fourth, changing the host zone does not reach the running container. On most distributions /etc/localtime is a symlink into /usr/share/zoneinfo. Docker resolves the symlink when it creates the container and mounts the file it pointed at then. Run timedatectl set-timezone afterwards and the host's symlink moves while the container keeps reading the old file until you recreate it.
Delete both lines and set TZ per service instead. Keeping the host itself on UTC is the calmer default, and if you do want the host to match the people who log into it, that is a separate decision: set the host zone once with timedatectl and leave it alone.
Why logs, cron jobs and database rows disagree
TZ is per process, so each process can be in a different zone, and nothing reconciles them. Four places where that shows up.
The timestamps from docker compose logs -t are stamped by the Docker daemon as it receives each line, not by your application, so TZ inside the container cannot change them. The text after that timestamp was formatted by the application in the application's zone. One log entry can therefore carry two different times for the same event, and neither is a bug.
A cron daemon inherits the environment it was started with. If the entrypoint runs crond directly, it gets TZ. If a supervisor or init layer starts it with a scrubbed environment, it does not, and the schedules run on whatever /etc/localtime says. Vixie-style cron and cronie accept a line in the crontab that settles it regardless of the process environment:
CRON_TZ=Europe/Berlin
30 3 * * * /usr/local/bin/backup.shBusybox crond, which is what Alpine images use, is a different implementation, so check crond --help in your image before relying on that line.
Application schedulers usually keep their own setting and ignore TZ for scheduling decisions. n8n is the clearest example: GENERIC_TIMEZONE decides when a schedule trigger fires, while TZ only affects how the process formats time. Set one and not the other and you get correct-looking log lines next to workflows that run an hour off, so the n8n timezone variables are worth setting as a pair.
Databases store and convert on their own terms. Postgres keeps timestamptz as UTC internally and converts on output using the session TimeZone, which starts from the value chosen when the data directory was created. A timestamp without time zone column converts nothing, so a wrong zone in the writing application becomes wrong data that no later setting repairs. MySQL reads its system_time_zone from the process environment at startup, but named zones such as Europe/Berlin are only accepted after the zone tables are loaded with mysql_tzinfo_to_sql. Until then it takes fixed offsets like +02:00 only.
The rule that avoids all of this: store in UTC, convert to a local zone at the edge where a human reads it. Set TZ where a process formats time for people, or where a scheduler needs local wall-clock behaviour across a daylight saving change.
Set TZ once for a whole stack
Repeating the zone in eight services invites one typo. Interpolate it from the project's .env file instead, with a default:
services:
app:
image: ghcr.io/example/app:1.4
environment:
TZ: ${TZ:-Etc/UTC}
worker:
image: ghcr.io/example/worker:1.4
environment:
TZ: ${TZ:-Etc/UTC}TZ=Europe/BerlinThe :-Etc/UTC default matters, because a missing .env would otherwise substitute an empty string. glibc reads an empty TZ as UTC, but some applications validate the value and refuse to start on it, so an explicit fallback is cheaper than that bug. Note also that the file named .env and the env_file: key are different features: .env substitutes ${TZ} into the Compose file itself, while env_file: passes variables into the container and substitutes nothing. Knowing which file Compose reads when saves you from setting a zone that never arrives. Self-hosting images from linuxserver.io put TZ next to PUID and PGID in the same environment block, and the PUID and PGID pair solves file ownership, which is a separate problem.
Changing TZ needs a recreate, not a restart
Environment variables are fixed when the container is created. docker compose restart app stops and starts that same container, with the same environment it was born with, so editing TZ and restarting changes nothing at all. This is the single most common reason people decide TZ is broken.
docker compose up -d app
docker compose up -d --force-recreate app
docker compose exec app printenv TZup -d compares the service definition against the running container and replaces the container when they differ, so it is normally all you need after editing the zone. Reach for --force-recreate when you want the container replaced regardless of what Compose thinks changed. Then confirm with printenv TZ inside the container, which reports what the process can actually see. Restart and recreate are not the same operation, and mixing them up hides every environment change, not only this one.
Why does my container still show the wrong time?
Work down this order, because each step rules out the one below it.
dateon the host: if the host wall time is wrong, no zone setting can help, and the host needs time sync.docker compose exec app printenv TZ: empty means the variable never arrived, so check for a restart instead of a recreate, a typo in the key, or anenv_filethat is not where you think it is.docker compose exec app date: right variable with a UTC offset means the zone files are missing from the image, which is case 2 above.- Right
datewith wrong behaviour in the application: the application has its own zone setting, andTZis not the one it reads.
FAQ
What are valid TZ values for Docker Compose?
Any tzdata zone name in Area/Location form, such as Europe/Berlin or America/Sao_Paulo. They come from the tzdata package, and they exist as files under /usr/share/zoneinfo in the container that reads them. List them on the host with timedatectl list-timezones, or with ls /usr/share/zoneinfo/Europe on a host without systemd. Avoid short abbreviations like IST, which are ambiguous and mostly not zone names.
Why does my container still show UTC after I set TZ?
There are two likely causes and they are easy to tell apart. Run docker compose exec <service> printenv TZ. If it prints nothing, the variable never reached the container, usually because the container was restarted instead of recreated. If it prints your zone and date still shows UTC, the image has no zone files, which is normal for Alpine, scratch and statically linked images. Install tzdata in the image and rebuild.
Do I need to bind mount /etc/localtime into the container?
No, and it costs you more than it gives. The mount pins every container to the host's zone, it cannot be written by an image that sets the zone itself, it goes stale when you change the host zone without recreating the container, and the companion /etc/timezone path does not exist on Rocky, Alma, Fedora or Arch hosts, where Docker then creates an empty directory in its place. Use the TZ variable per service.
Does changing TZ need a restart or a recreate?
A recreate. Environment variables are part of the container's configuration at creation time, so docker compose restart gives you the old value back. Run docker compose up -d <service>, which replaces the container because its definition changed, or add --force-recreate to replace it unconditionally. Verify with printenv TZ inside the new container.
Can the container clock be a different time from the host?
No. The container shares the host kernel's clock, so the underlying timestamp is identical. Only the formatting differs, which is why two containers on one host can print two different local times for the same instant and both be correct. Setting the clock from inside needs CAP_SYS_TIME, which Docker drops, and it would change the host's clock for everything on the box.