Non-root user workflows

Table of Contents

Running containers as root is a security risk. I need reusable helpers to create users, switch to them, and set up their environment — without repeating the same adduser boilerplate in every project.

1. Creating a user with groups and sudo

I need to create a user with a specific UID, optional group memberships, and optional passwordless sudo access. This covers both development containers (where sudo is handy) and production ones (where it's not).

The username defaults to Lib.default_username so it's defined once and overridable globally — pass None (the default) and the library-level setting applies.

.with_env_variable sets an environment variable inside the container, like ENV in a Dockerfile. We use it here to set HOME and later to prepend ~/.local/bin to PATH (with expand=True so $PATH is resolved at build time).

@function
def setup_user(
    self,
    ctr: dagger.Container,
    uid: int = 1000,
    username: str | None = None,
    sudoer: bool = False,
    shell: str = "/bin/sh",
    groups: list[str] = (),
) -> dagger.Container:
    """Create a user with optional groups and sudo access."""
    username = username or self.default_username
    q_username, q_shell = map(shlex.quote, (username, shell))
    ctr = (
        ctr
        .with_env_variable("HOME", f"/home/{username}")
        .with_exec([
            "sh", "-c",
            "command -v adduser > /dev/null"
            " || { command -v apt > /dev/null"
            " && apt update && apt install --yes adduser ; }",
        ])
        .with_exec([
            "sh", "-c",
            f"addgroup --gid {uid} --system {q_username}"
            f" && adduser --uid {uid} --shell {q_shell}"
            f" --disabled-password --gecos '' {q_username}"
            f" --ingroup {q_username}"
            f" && chown -R {q_username}:{q_username} /home/{q_username}",
        ])
    )
    if sudoer:
        ctr = ctr.with_exec([
            "sh", "-c",
            f'mkdir -p /etc/sudoers.d'
            f' && echo "{q_username} ALL=(ALL) NOPASSWD: ALL"'
            f' >> /etc/sudoers.d/username',
        ])
    if groups:
        groups_str = shlex.join(groups)
        ctr = ctr.with_exec([
            "sh", "-c",
            f"for group in {groups_str} ; do"
            f' {{ grep -q -E "^${{group}}:" /etc/group'
            f" || addgroup --system $group ; }}"
            f" && adduser {q_username} $group ; done",
        ])
    ctr = ctr.with_env_variable(
        "PATH", f"/home/{username}/.local/bin:$PATH", expand=True,
    )
    return ctr

2. Switching to a user

Once a user is created, I want to switch to it and land in their home directory — just like logging in.

.with_user switches the active user for all subsequent commands, like USER in a Dockerfile. Combined with .with_workdir it simulates a login into the user's home directory.

@function
def as_user(
    self,
    ctr: dagger.Container,
    username: str | None = None,
) -> dagger.Container:
    """Switch to user and set workdir to their home."""
    username = username or self.default_username
    return (
        ctr
        .with_env_variable("HOME", f"/home/{username}")
        .with_user(username)
        .with_workdir(f"/home/{username}")
    )

3. The full combo: create + switch

Most of the time I just want "give me a user and switch to it". This combines setup_user and as_user in one call.

@function
def use_user(
    self,
    ctr: dagger.Container,
    uid: int = 1000,
    username: str | None = None,
    sudoer: bool = False,
    groups: list[str] = (),
) -> dagger.Container:
    """Create a user and switch to it (SETUP_USER + AS_USER)."""
    ctr = self.setup_user(ctr, uid=uid, username=username, sudoer=sudoer, groups=groups)
    ctr = self.as_user(ctr, username=username)
    return ctr

We test user creation via the alpine-user target (which calls use_user):

dagger call alpine-user with-exec --args="whoami" stdout
dagger call alpine-user with-exec --args="pwd" stdout

Author: root

Created: 2026-04-18 Sat 21:16

Validate