Debian-based production containers

Table of Contents

Some of my services need libraries or tools that are only well-packaged on Debian. I want Debian slim containers with the same defaults as my Alpine ones: correct timezone, no unnecessary packages pulled in, and easy user setup.

1. Disabling automatic recommends

Debian's apt installs recommended packages by default, which bloats images. I disable this globally so every apt install stays lean.

@function
def debian_no_auto_install(self, ctr: dagger.Container) -> dagger.Container:
    """Disable apt recommends and suggests."""
    return ctr.with_exec([
        "sh", "-c",
        'echo \'APT::Install-Recommends "0";\' > /etc/apt/apt.conf.d/01norecommend'
        " && "
        'echo \'APT::Install-Suggests "0";\' >> /etc/apt/apt.conf.d/01norecommend',
    ])

2. Setting the timezone on Debian

Debian ships timezone data by default, so it's simpler than Alpine: just symlink the right zoneinfo file.

@function
def debian_set_tz(self, ctr: dagger.Container) -> dagger.Container:
    """Set timezone on a Debian container (uses Lib.timezone)."""
    return (
        ctr
        .with_exec(["rm", "/etc/localtime"])
        .with_exec(["ln", "-sf", f"/usr/share/zoneinfo/{self.timezone}", "/etc/localtime"])
    )

3. Cleaning up apt caches

After installing packages, I clean up to keep the image small.

@function
def debian_apt_cleanup(self, ctr: dagger.Container) -> dagger.Container:
    """Clean apt caches."""
    return ctr.with_exec([
        "sh", "-c",
        "apt-get --quiet clean && rm -rf /var/lib/apt/lists/*",
    ])

4. A base Debian container

This is the Debian equivalent of my Alpine target: slim image with timezone set, no recommends, and optional extra packages.

@function
def debian(self, distro_packages: list[str] = ()) -> dagger.Container:
    """Debian slim with timezone set, no auto-install, and optional extra packages."""
    ctr = dag.container().from_(self.pinned(self._debian_image))
    ctr = self.debian_no_auto_install(ctr)
    ctr = self.debian_set_tz(ctr)
    if distro_packages:
        packages_str = shlex.join(distro_packages)
        ctr = ctr.with_exec([
            "sh", "-c",
            "{ apt-get --quiet update"
            f" && apt-get --quiet install --yes {packages_str}"
            " ; } > /tmp/log 2>&1 || { cat /tmp/log; exit 1; }",
        ])
        ctr = self.debian_apt_cleanup(ctr)
    return ctr
dagger call debian with-exec --args="sh","-c","readlink -f /etc/localtime" stdout
dagger call debian with-exec --args="cat","/etc/apt/apt.conf.d/01norecommend" stdout
dagger call debian --distro-packages=curl with-exec --args="which","curl" stdout

5. Debian with a default user

Same pattern as Alpine: a non-root user ready to go.

@function
def debian_user(self, distro_packages: list[str] = ()) -> dagger.Container:
    """Debian with a default user."""
    ctr = self.debian(distro_packages=distro_packages)
    return self.use_user(ctr)
dagger call debian-user with-exec --args="id","sam" stdout

6. Python with a user and virtualenv on Debian

For Python services that need Debian-specific libraries (e.g., for native extensions), I want the same venv setup as Alpine but on Debian.

@function
def debian_python_user_venv(
    self,
    distro_packages: list[str] = (),
    groups: list[str] = (),
    pip_packages: list[str] = (),
    work_dir: str = "/app",
) -> dagger.Container:
    """Debian with python, user, and a virtualenv."""
    ctr = self.debian(distro_packages=["python3-venv"] + list(distro_packages))
    return self.python_user_venv(ctr, groups=groups, pip_packages=pip_packages, work_dir=work_dir)
dagger call debian-python-user-venv with-exec --args="sh","-c","test -d /app/venv && /app/venv/bin/python --version" stdout

7. Extracting the localtime file

Distroless containers can't install packages. I extract the timezone file from the existing debian() container so I can copy it into distroless images.

.file(path) extracts a single file from the container as a File artifact. Since debian() already sets the timezone, we just pull /etc/localtime from it — no need to create a separate container.

@function
def debian_localtime(self) -> dagger.File:
    """Extract the localtime file from a Debian container."""
    return self.debian().file("/etc/localtime")
TMP="${TMP:-$(mktemp -d)}"
dagger call debian-localtime export --path="$TMP/out/localtime" > /dev/null
test -s "$TMP/out/localtime" && echo "non-empty"

8. Image reference for pin management

See alpine for the convention.

@property
def _debian_image(self) -> str:
    return f"debian:{self.debian_tag}"

Author: root

Created: 2026-04-18 Sat 21:16

Validate