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