Lightweight timezone-aware containers
Table of Contents
I need containers where logs and cron jobs use the correct timezone by
default, without having to configure it in every project. Alpine is my
go-to base image because it's small and fast to pull. The timezone
defaults to Europe/Paris but is configurable via the Lib.timezone
field.
1. Setting the timezone on Alpine
On Alpine, timezone data comes from the tzdata package. We install it,
copy the zoneinfo file, then remove the package to keep the image small.
.with_exec runs a shell command inside the container — the
equivalent of RUN in a Dockerfile. Here it installs tzdata, copies
the zoneinfo file, and removes the package in a single step so the
final image stays small.
@function
def alpine_set_tz(self, ctr: dagger.Container) -> dagger.Container:
"""Set timezone on an Alpine container (uses Lib.timezone)."""
tz = self.timezone
return ctr.with_exec([
"sh", "-c",
"apk --quiet add --update tzdata"
f" && cp /usr/share/zoneinfo/{tz} /etc/localtime"
f' && echo "{tz}" > /etc/timezone'
" && apk --quiet del tzdata",
])
We can verify the timezone is correctly set:
dagger call alpine with-exec --args="cat","/etc/timezone" stdout
2. A base Alpine container with optional extra packages
Sometimes I need additional tools. The alpine target gives me a
timezone-ready Alpine with an easy way to add packages.
dag.container() creates a new empty container — the starting point
for every pipeline step. .from_ sets the base image, like FROM in
a Dockerfile. We chain with_exec to install any extra packages the
caller requested.
@function
def alpine(self, distro_packages: list[str] = ()) -> dagger.Container:
"""Alpine with timezone set and optional extra packages."""
ctr = dag.container().from_(self.pinned(self._alpine_image))
ctr = self.alpine_set_tz(ctr)
if distro_packages:
ctr = ctr.with_exec(["apk", "--quiet", "add"] + list(distro_packages))
return ctr
dagger call alpine --distro-packages=curl with-exec --args="which","curl" stdout
3. A non-root user for safer workflows
Running as root in containers is risky. I want a default user ready to go, so I don't have to repeat user-creation boilerplate.
@function
def alpine_user(
self,
distro_packages: list[str] = (),
groups: list[str] = (),
uid: int = 1000,
) -> dagger.Container:
"""Alpine with a default user."""
ctr = self.alpine(distro_packages=distro_packages)
return self.use_user(ctr, uid=uid, groups=groups)
dagger call alpine-user with-exec --args="id","sam" stdout
dagger call alpine-user --uid=2000 with-exec --args="id","-u" stdout
4. Timezone artifacts for other containers
Some containers (like distroless) can't run apk. I need to extract the
timezone files from Alpine so I can copy them in.
.file(path) pulls a single file out of a container as a File artifact. dag.directory() creates a new empty Directory and .with_file adds files into it. Together they let us bundle timezone files extracted from Alpine so other containers can import them.
@function
def alpine_tz(self) -> dagger.Directory:
"""Extract /etc/localtime and /etc/timezone from Alpine as artifacts."""
ctr = self.alpine()
return (
dag.directory()
.with_file("localtime", ctr.file("/etc/localtime"))
.with_file("timezone", ctr.file("/etc/timezone"))
)
TMP="${TMP:-$(mktemp -d)}"
dagger call alpine-tz export --path="$TMP/out" > /dev/null
ls "$TMP/out" | sort
5. Python on Alpine
I often need a quick Python environment on Alpine for scripts and tools.
@function
def alpine_python(self, distro_packages: list[str] = ()) -> dagger.Container:
"""Alpine with python3 and pip."""
return self.alpine(distro_packages=["python3", "py3-pip"] + list(distro_packages))
dagger call alpine-python with-exec --args="python3","--version" stdout
6. Python with a user and virtualenv
For application development, I want a full setup: Alpine + Python + a
non-root user + a virtualenv ready at /app/venv.
.with_workdir changes the container's working directory — like
WORKDIR in a Dockerfile. Setting it to /app means subsequent
commands and the virtualenv land in the right place.
@function
def alpine_python_user_venv(
self,
distro_packages: list[str] = (),
groups: list[str] = (),
pip_packages: list[str] = (),
work_dir: str = "/app",
) -> dagger.Container:
"""Alpine with python, user, and a virtualenv."""
ctr = self.alpine_python(distro_packages=distro_packages)
return self.python_user_venv(ctr, groups=groups, pip_packages=pip_packages, work_dir=work_dir)
dagger call alpine-python-user-venv with-exec --args="sh","-c","test -d /app/venv && /app/venv/bin/python --version" stdout
7. Image reference for pin management
The _alpine_image property is the single definition of which
Alpine image this mixin uses. It is referenced by alpine() above
and discovered automatically by PinsMixin._image_tags() (any
property whose name ends with _image is collected for pinning).
@property
def _alpine_image(self) -> str:
return f"alpine:{self.alpine_version}"