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}"

Author: root

Created: 2026-04-18 Sat 21:16

Validate