Skip to content

Add conditional logic to drop permissions for unprivileged Docker#798

Open
jpiccari wants to merge 2 commits intoDonkie:masterfrom
jpiccari:features/unprivileged-docker
Open

Add conditional logic to drop permissions for unprivileged Docker#798
jpiccari wants to merge 2 commits intoDonkie:masterfrom
jpiccari:features/unprivileged-docker

Conversation

@jpiccari
Copy link
Copy Markdown

This change enables the container to run in unprivileged mode (without --privileged or additional capabilities) by making permission drops conditional:

  • Changed users group GID from 1000 to 1001 to avoid conflicts with the app user's primary group (UID 1000)
  • Only modify user/group IDs when PUID/PGID environment variables differ from the default value of 1000, avoiding unnecessary privilege requirements
  • Skip su-exec entirely when running as the default user, since changing user context requires additional capabilities that unprivileged containers don't have

This allows the container to work in restricted environments like Kubernetes with security contexts or rootless Docker while maintaining backward compatibility for users who customize PUID/PGID.

Fixes #791

@jpiccari jpiccari force-pushed the features/unprivileged-docker branch from 08180f6 to 76ca920 Compare January 6, 2026 02:37
@jpiccari
Copy link
Copy Markdown
Author

@Donkie this feels like a easy one to merge and would resolve an open issue. I know this project isn't your main focus right now, but let me know if there is anything further you need to get this one in.

@Donkie
Copy link
Copy Markdown
Owner

Donkie commented Jan 18, 2026

Ya this looks good, sorry for the long delay. Have you verified that this doesn't break any existing installs? I'm thinking about the id change of the group. The group of the volume mount that people have made will be different. Is the group id change to 1001 really necessary?

@jdh313
Copy link
Copy Markdown

jdh313 commented Jan 20, 2026

Thanks for working on this! I was curious about the approach so I tested it locally. It looks like in the default case (PUID/PGID=1000), the condition evaluates to false, gosu gets skipped, and uvicorn ends up running as root instead of the app user. I think this might be a regression from the current behavior.

I'm also not sure if checking whether PUID/PGID differs from their defaults would solve what the linked issue is asking for, which is to run rootless. Would it be simpler to check if [ "$(id -u)" = "0" ] in the entrypoint to see if it's running as root, and use that to determine if gosu should be run?

@jpiccari
Copy link
Copy Markdown
Author

jpiccari commented Feb 4, 2026

@Donkie now I'm the one with the long reply, sorry about that. Yes I did regression test, it works normally and when run as other users. I also specifically tested in a k8s cluster that enforces non-root containers (as in the linked issue). All cases work fine. I have not done upgrade regression checks though.

The change to 1001 came during testing, I noticed some unexpected results when building the docker and/or when running the entrypoint script to update the app group ID. The UID will be the same, so I expect no issue but also didn't test it explicitly. Since the official image only supports the entrypoint run as root maybe we can add some change to the entrypoint (when run as root) to validate the folder ownership and permissions. I can pick that up if you can confirm the approach you think should be taken.

@jdh313 The behavior you describe seems expected to me. I expect users to provide a user/group to docker/k8s when running containers. Setting PUID/GID is meant only for adding the specified UID/GID to the app user/group (this requires root privileges or similar). I can't think of a reason someone would want to do this, but figured I'd keep it configurable in case someone had needs outside my expectations.

@eib
Copy link
Copy Markdown

eib commented Feb 12, 2026

Hi, I'm new to unprivileged Docker (and Spoolman) and I was having issues with file/folder permissions when running Spoolman with your PR.

What are the intended/expected folder (esp data folder) permission requirements in unprivileged mode?

I seem to recall running sudo chown -R 1000:1001 path/to/data (as suggested by the container's error logs) and that didn't seem to work. I ended up chmod -R o+w path/to/data, which isn't ideal.

@jdh313
Copy link
Copy Markdown

jdh313 commented Feb 15, 2026

Thanks for the detailed response, @jpiccari .

I see the logic — PUID/PGID as a root-only remapping feature, with --user handling non-root. My concern is that this changes the existing contract: today, gosu always drops to app, so current users never run uvicorn as root with defaults. This also drives the GID 1001 change that @Donkie flagged.

The established pattern for this is checking the actual runtime UID rather than env var values. The official Postgres image, Sentry's Symbolicator, and others all use a variation of:

if [ "$(id -u)" = '0' ]; then
    exec gosu <app-user> "$@"
fi

LinuxServer.io (who originated PUID/PGID) takes the same approach — those vars only apply when running as root.

An id -u check would preserve existing behavior, handle the K8s case from #791, and eliminate the need for the GID change. @eib's permission issues on this thread suggest the current approach is tripping up users in practice.

Happy to help with the implementation, or defer to @Donkie on direction.

@jpiccari
Copy link
Copy Markdown
Author

@jdh313 I think I see some recurring concerns that you have and maybe I can help provide context.

The GID change is required. The issue is that the users group currently occupies GID 1000, the app group gets GID 1001. This gets smoothed over by the container running as root and the call to groupmod in the entrypoint script. Adding support for non-root containers means this issue must be addressed, or there needs to be some awkward documentation to inform users that app group is 1001 when run as non-root. This is true regardless of the way we check if gosu, usermod, or groupmod are required.

Many ways to manage the check. Your specific recommendation comes with the issue that it silently disables a documented feature. Following the principle of least surprise, we should at least include some additional check if the user is trying to run a non-root container and set a custom PUID/PGID. However, this check now looks very similar to the one in the PR.

Not sure what issue @eib is having but I was unable to reproduce it. Some detailed steps would help to understand what is happening. However, I think it is very unlikely for users to migrate between root/non-root containers so not sure how much that sort of setup should be explored.

@jdh313
Copy link
Copy Markdown

jdh313 commented Feb 16, 2026

I want to make sure we're on the same page about the root case first, since that's the default for most users. Right now, without this PR, docker run spoolman with no flags runs the entrypoint as root. First, groupmod fixes the GID at runtime, and then gosu runs uvicorn as the app user.

In this PR, that default case skips both groupmod and gosu, so uvicorn ends up running as root instead of app. Before looking at the non-root case, I think this could be worth discussing.

@jpiccari
Copy link
Copy Markdown
Author

That is my bad I was having bad tunnel vision on the other things and was completely overlooking that. There is typos in the condition, it should be checking if the current UID/GID matches the requested PUID/PGID like this:

[ $PUID -ne $(id -u) -o $PUID -ne $(id -g) ]

Before I update the PR, let's figure finalize the decision points.

  1. Should the users GID change to 1001?
  2. Where/how to include checks for gosu?
  3. Where/how to include checks for usermod/groupmod?

For me, 1 is a must since the current behavior (2 groups sharing the same GID) is somewhat undefined. Consider the chown() syscall, which takes a GID. I can see handling this in some other way, but users and app groups should not have colliding GIDs.

For the gosu check, I think your suggestion of checking if root or not is reasonable.

For the checks on updating the UID/GID, I think this needs to be separate from the gosu check. The reason is that if the user sets PUID/PGID but it is not run as root, we should provide some kind of reasonable error message.

@jdh313
Copy link
Copy Markdown

jdh313 commented Feb 18, 2026

  1. Do we still need the users group at all? Could we just have app at GID 1000 and drop the users group entirely?
    1. I think checking if root is the right approach for the gosu check.
  2. For the PUID/PGID + non-root case, what if we just error whenever they're set and the container isn't running as root? usermod/groupmod need root to do anything, and even if the values happen to match --user they'd silently have no effect, which I think could be misleading.

@jpiccari
Copy link
Copy Markdown
Author

Seems users comes from the base image, but has GID of 100. Not sure what it is used for but I've been running with it set to 1001 for a while and have noticed no issues.

% docker run python:3.14-slim-bookworm getent group
root:x:0:
daemon:x:1:
bin:x:2:
sys:x:3:
adm:x:4:
tty:x:5:
disk:x:6:
lp:x:7:
mail:x:8:
news:x:9:
uucp:x:10:
man:x:12:
proxy:x:13:
kmem:x:15:
dialout:x:20:
fax:x:21:
voice:x:22:
cdrom:x:24:
floppy:x:25:
tape:x:26:
sudo:x:27:
audio:x:29:
dip:x:30:
www-data:x:33:
backup:x:34:
operator:x:37:
list:x:38:
irc:x:39:
src:x:40:
shadow:x:42:
utmp:x:43:
video:x:44:
sasl:x:45:
plugdev:x:46:
staff:x:50:
games:x:60:
users:x:100:
nogroup:x:65534:

PUID/PGID are always set (default values at the top of the entrypoint), so checking if they are set is not an option. You can check if they are set to the real values of the app user/group, or you can check if they are set to the hardcoded defaults at the top of the entrypoint.sh (this is kind fragile if the dockerfile changes the ids).

@jdh313
Copy link
Copy Markdown

jdh313 commented Feb 24, 2026

Could you test leaving the users GID at 100 and see if it causes issues?

And good catch on the defaults! I totally missed that. What if you did something like this:

if [ "$(id -u)" = "0" ]; then
      PUID=${PUID:-1000}
      PGID=${PGID:-1000}
      # usermod/groupmod goes here
      exec gosu app "$@"
  else
      if [ -n "${PUID}" ] || [ -n "${PGID}" ]; then
          echo "ERROR: PUID/PGID requires root." >&2
          exit 1
      fi
      exec "$@"
  fi

Basically: If run as root, get the UID/GID or use 1000 as default. Else (running as user) if a UID/GID is defined, error.

@jpiccari
Copy link
Copy Markdown
Author

diff --git a/Dockerfile b/Dockerfile
index 7f03515..a1f0fc4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,8 +44,7 @@ RUN apt-get update && apt-get install -y \
     && rm -rf /var/lib/apt/lists/*

 # Add local user so we don't run as root
-RUN groupmod -g 1001 users \
-    && useradd -u 1000 -U app \
+RUN useradd -u 1000 -U app \
     && usermod -G users app \
     && mkdir -p /home/app/.local/share/spoolman \
     && chown -R app:app /home/app/.local/share/spoolman
@@ -74,4 +73,4 @@ RUN echo "GIT_COMMIT=${GIT_COMMIT}" > build.txt \

This adjustment to the dockerfile has the following impact on users and groups.

% docker run --rm spoolman getent passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
app:x:1000:1000::/home/app:/bin/sh
% docker run --rm spoolman getent group
root:x:0:
daemon:x:1:
bin:x:2:
sys:x:3:
adm:x:4:
tty:x:5:
disk:x:6:
lp:x:7:
mail:x:8:
news:x:9:
uucp:x:10:
man:x:12:
proxy:x:13:
kmem:x:15:
dialout:x:20:
fax:x:21:
voice:x:22:
cdrom:x:24:
floppy:x:25:
tape:x:26:
sudo:x:27:
audio:x:29:
dip:x:30:
www-data:x:33:
backup:x:34:
operator:x:37:
list:x:38:
irc:x:39:
src:x:40:
shadow:x:42:
utmp:x:43:
video:x:44:
sasl:x:45:
plugdev:x:46:
staff:x:50:
games:x:60:
users:x:100:app
nogroup:x:65534:
app:x:1000:

Basically, users is still GID 100 with app as a member, and app has UID/GID 1000. Running the image like this seems fine (I tested adding a spool and saving). I'm concerned about my lack of understanding of why users was set to GID 1000 in the first place. I would like @Donkie to weigh in here to make sure there isn't something being overlooked.

Playing around with the logic a bit I think this is the most clear experience for users. If they set PUID/PGID and run non-root, they will get an error that is hopefully clear (feedback on the exact error message welcome) that their configuration is not valid. I went digging in the gosu source to see if we could remove the final condition and simply always run gosu but unfortunately it calls setuid/setgid syscalls in all conditions and will error out if run as app user.

#!/bin/sh

PUID=${PUID:-1000}
PGID=${PGID:-1000}
SPOOLMAN_PORT=${SPOOLMAN_PORT:-8000}
SPOOLMAN_HOST=${SPOOLMAN_HOST:-0.0.0.0}

fail() {
    echo "$1" >&2
    exit 1
}

if [ "$(id -u app)" -ne "$PUID" ]; then
    usermod -o -u "$PUID" app ||
        fail "Failed to update app UID to $PUID"
fi

if [ "$(id -g app)" -ne "$PGID" ]; then
    groupmod -o -g "$PGID" app ||
        fail "Failed to update app GID to $PGID"
fi

if [ "$(id -u)" -eq 0 ]; then
    exec gosu "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
else
    exec uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
fi

@jdh313
Copy link
Copy Markdown

jdh313 commented Feb 26, 2026

Thanks for testing with users at GID 100, I think that confirms the groupmod line in the Dockerfile. We should definitely get confirmation once the rest of the PR is done that it can be removed.

On the entrypoint script, I wanted to make sure I wasn't biased toward the approach I was picturing, so I asked Claude to run a structured debate, giving two separate agents each approach and asking them to advocate for it, with a final "fact check" agent to check both before asking the main agent to make a ruling. It returned the following analysis. Of course, neither myself nor AI are infallible, and I'm definitely happy to discuss further.


Debate: Your Approach (top-level PUID/PGID) vs. Root-Scoped PUID/PGID

For: Top-level PUID/PGID (your version)

  • Centralized defaults — declaring PUID/PGID at the top of the script is more readable; operators can see all defaults at a glance without tracing into conditional branches.
  • Separation of concerns — UID remapping and privilege dropping are separate steps. Conceptually clean.
  • Smaller behavioral diff — closer to the existing entrypoint structure for the root case, which matters given Donkie's concern about breaking existing installs.
  • Confidence: 38%

For: Root-scoped PUID/PGID

  • Structural correctnessusermod/groupmod require root. Scoping them inside if [ "$(id -u)" = "0" ] means they only run when they can succeed. The top-level version runs them unconditionally, so every non-root invocation (--user, runAsNonRoot) crashes on privileged syscalls before reaching the gosu branch. (gosu issue #11, LinuxServer.io non-root docs)

  • Actionable error messages — non-root + PUID/PGID produces "Remove PUID/PGID or run as root" instead of a raw usermod: Permission denied.

  • Ecosystem alignment — every major project scopes privileged operations inside a root check:

    Project Pattern PUID/PGID?
    Official Postgres id -u == 0 → setup + gosu No
    LinuxServer.io Root-only; PUID/PGID "will not have any effect" with --user Yes, root-only
    Sentry Symbolicator id -u == 0 → gosu No
  • gosu design intent — author Tianon: "processes that are root, but don't want to be" — gating inside root branch is correct.

  • Correct orderinggroupmod before usermod ensures the target GID exists before the UID change references it.

  • Confidence: 72%

Fact-Check Notes

  • The "centralized defaults = industry standard" claim for top-level PUID/PGID is overgeneralized — LinuxServer.io (originators of PUID/PGID) gate usermod/groupmod inside a root check, not at top level.
  • gosu quote verified accurate from issue #11.
  • The ordering between groupmod/usermod is a dependency issue, not a race condition (single-threaded script), but getting the order right still matters.

Verdict (78% confidence)

Root-scoped is the better design. The top-level version has a structural flaw: it crashes on every non-root invocation, which is the use case this PR exists to solve. If the usermod/groupmod calls were moved inside the root guard, the two approaches would converge — at that point the only difference is where PUID/PGID defaults are declared, which is a style preference.

Trade-off acknowledged: if a user migrates from Docker Compose (with PUID=1000) to K8s non-root and forgets to remove the env var, this hard-fails instead of starting. I think that's the right behavior — silent misconfiguration is worse than a clear error.


Here's what the root-scoped version looks like:

#!/bin/sh

SPOOLMAN_PORT=${SPOOLMAN_PORT:-8000}
SPOOLMAN_HOST=${SPOOLMAN_HOST:-0.0.0.0}

fail() {
    echo "$1" >&2
    exit 1
}

if [ "$(id -u)" = "0" ]; then
    PUID=${PUID:-1000}
    PGID=${PGID:-1000}

    if [ "$(id -g app)" -ne "$PGID" ]; then
        groupmod -o -g "$PGID" app ||
            fail "Failed to update app GID to $PGID"
    fi

    if [ "$(id -u app)" -ne "$PUID" ]; then
        usermod -o -u "$PUID" app ||
            fail "Failed to update app UID to $PUID"
    fi

    exec gosu "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
else
    if [ -n "${PUID}" ] || [ -n "${PGID}" ]; then
        fail "ERROR: PUID/PGID requires running as root. Remove PUID/PGID or run the container as root."
    fi

    exec uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
fi

@jpiccari jpiccari force-pushed the features/unprivileged-docker branch from 602e0f0 to aaac97a Compare February 28, 2026 18:42
This change enables the container to run in unprivileged mode (without
--privileged or additional capabilities) by making permission drops
conditional:

- Changed users group GID from 1000 to 1001 to avoid conflicts with
  the app user's primary group (UID 1000)
- Only modify user/group IDs when PUID/PGID environment variables differ
  from the default value of 1000, avoiding unnecessary privilege
  requirements
- Skip su-exec entirely when running as the default user, since changing
  user context requires additional capabilities that unprivileged
  containers don't have

This allows the container to work in restricted environments like
Kubernetes with security contexts or rootless Docker while maintaining
backward compatibility for users who customize PUID/PGID.

Fixes Donkie#791
@jpiccari jpiccari force-pushed the features/unprivileged-docker branch from aaac97a to 1375586 Compare February 28, 2026 18:43
@jpiccari
Copy link
Copy Markdown
Author

It would be silly to debate with a stochastic next token generator, so I guess this is my last post in this thread. Thanks again for your work @Donkie, hope to see some kind of rootless container support in the future so I can move back to tracking your official images. My last update added the entrypoint changes I mentioned and removes the groupmod on users from the Dockerfile. If you think there is an issue with this approach I trust you can make the appropriate modifications.

I've also opened a feature request with gosu to make it idempotent tianon/gosu#174 which could further simplify the entrypoint script.

Simplicity is a great virtue but it requires hard work to achieve it and education to appreciate it. And to make matters worse: complexity sells better.
– Edsger Wybe Dijkstra

@Donkie
Copy link
Copy Markdown
Owner

Donkie commented Mar 1, 2026

I think having both the users and app groups was an oversight on my part, we shouldn't need to mess with both.
I'm very much for allowing rootless containers. My main concern is to maintain backwards compatibility, I don't want to release an update that breaks folder permissions for all of our docker users. As long as you can guarantee that, it's all good.

Comment thread Dockerfile Outdated
RUN groupmod -g 1000 users \
&& useradd -u 1000 -U app \
RUN useradd -u 1000 -U app \
&& usermod -G users app \
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

based on the discussions, you can remove this usermod part

Comment thread Dockerfile
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing locally with podman, I had to add

# Add local user so we don't run as root
RUN useradd -u 1000 -U app

here to allow it to build, so could be good to add.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into the same issue after a rebase, but I solved it by removing the --chown from the python-builder stage. These weren't needed and all COPY that move files into python-runner have --chown so the final image is identical and you don't need to add the user. This is a local change I have already, but let me know your thoughts.

diff --git a/Dockerfile b/Dockerfile
index 3cc8a0a..85b3e69 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,9 +25,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
     uv sync --locked --no-install-project

 # Copy and install app
-COPY --chown=app:app migrations /home/app/spoolman/migrations
-COPY --chown=app:app spoolman /home/app/spoolman/spoolman
-COPY --chown=app:app alembic.ini README.md uv.lock pyproject.toml /home/app/spoolman/
+COPY migrations /home/app/spoolman/migrations
+COPY spoolman /home/app/spoolman/spoolman
+COPY alembic.ini README.md uv.lock pyproject.toml /home/app/spoolman/
 RUN --mount=type=cache,target=/root/.cache/uv \
     uv sync --locked

@Donkie
Copy link
Copy Markdown
Owner

Donkie commented Mar 16, 2026

tested a bit and it should work fine with existing user's setup so I think we can continue with this, just handle my two comments

@jpiccari
Copy link
Copy Markdown
Author

I think having both the users and app groups was an oversight on my part, we shouldn't need to mess with both. I'm very much for allowing rootless containers. My main concern is to maintain backwards compatibility, I don't want to release an update that breaks folder permissions for all of our docker users. As long as you can guarantee that, it's all good.

I did several tests with different repos, I think your concern about backward compatibility is really important. The main thing that will trip users up is if they switch their deployment configuration.

# Start User Migration Outcome
1 root n/a Works
2 root rootless Maybe works*
3 rootless n/a Works
4 rootless root Works
5 rootless rootless (new UID) Does not work
6 rootless rootless (new GID) Anyones guess lol

Scenario 2 works as long as the container is run rootless with the same UID/GID as the root container used when dropping permissions. PUID/PGUID changes or running as some UID/GID other than 1000 may have issues.

tested a bit and it should work fine with existing user's setup so I think we can continue with this, just handle my two comments

I'll take a look tomorrow and add those changes in.

@jpiccari
Copy link
Copy Markdown
Author

I think all the changes you are looking for are in, and/or addressed in some other way. The matrix in the comment above shows the supported migration paths. I don't think anyone will have issues unless they attempt migrating in weird ways.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docker container fails to run if its already running as non root

4 participants