{"id":51702578,"url":"https://github.com/jammsen/docker-user-jail-mvp","last_synced_at":"2026-07-16T12:36:31.649Z","repository":{"id":355883925,"uuid":"752318252","full_name":"jammsen/docker-user-jail-mvp","owner":"jammsen","description":"A minimal Docker integration template that demonstrates how to run a container as root during initialisation and then permanently hand off control to an unprivileged user at runtime — with no way back.","archived":false,"fork":false,"pushed_at":"2026-06-20T21:02:10.000Z","size":15,"stargazers_count":3,"open_issues_count":1,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-07-16T12:36:29.440Z","etag":null,"topics":["docker","exec","gosu","jail","security"],"latest_commit_sha":null,"homepage":"","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jammsen.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-02-03T16:07:05.000Z","updated_at":"2026-05-22T19:02:27.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/jammsen/docker-user-jail-mvp","commit_stats":null,"previous_names":["jammsen/docker-user-jail-mvp"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/jammsen/docker-user-jail-mvp","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jammsen%2Fdocker-user-jail-mvp","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jammsen%2Fdocker-user-jail-mvp/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jammsen%2Fdocker-user-jail-mvp/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jammsen%2Fdocker-user-jail-mvp/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jammsen","download_url":"https://codeload.github.com/jammsen/docker-user-jail-mvp/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jammsen%2Fdocker-user-jail-mvp/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35544514,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-16T02:00:06.687Z","response_time":83,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["docker","exec","gosu","jail","security"],"created_at":"2026-07-16T12:36:30.967Z","updated_at":"2026-07-16T12:36:31.640Z","avatar_url":"https://github.com/jammsen.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# docker-user-jail-mvp\n\nA minimal Docker integration template that demonstrates how to run a container as root during initialisation and then permanently hand off control to an unprivileged user at runtime — with no way back.\n\n---\n\n## The problem\n\nDocker containers often need to run as root for setup tasks: installing packages, creating users, adjusting file ownership on mounted volumes. But leaving the actual workload running as root is a security risk — if the application is ever compromised, an attacker has full root access inside the container and a much easier path to the host.\n\nThe naive fix — using the `USER` directive in the Dockerfile — doesn't work cleanly when the UID/GID need to match the host's file ownership (which is only known at runtime, not build time).\n\nThis project solves both problems cleanly.\n\n---\n\n## How it works\n\nThe container starts as root. The `entrypoint.sh` does all the setup that requires elevated privileges:\n\n1. Verifies it is actually running as root — fails fast with a clear error if not\n2. Rejects `PUID=0` or `PGID=0` — running the workload as root is never allowed\n3. Creates the group with the requested GID (`PGID`) if it does not already exist\n4. Creates the user with the requested UID (`PUID`) if it does not already exist\n5. Fixes ownership of the home directory\n6. Calls `exec gosu \u003cuser\u003e:\u003cgroup\u003e \u003ccommand\u003e`\n\nThat last step is the jail. `exec` **replaces** the current process entirely — the root shell that ran the entrypoint ceases to exist. `gosu` drops privileges at the kernel level and hands control to your application. From that point forward, PID 1 is your application, running as an unprivileged user. Root is gone. There is nothing to escalate back to.\n\n---\n\n## What is EUID?\n\n`EUID` stands for **Effective User ID** — the UID the kernel actually checks when deciding whether a process has permission to do something. In most cases it matches `UID` (the real user ID), but they can differ when a process uses `setuid` to temporarily elevate or drop privileges. For our purposes: if `EUID` is `0`, the process has root-level kernel permissions right now.\n\nThis is the value `entrypoint.sh` checks first, before doing any setup work.\n\n---\n\n## Workflow\n\n```mermaid\nflowchart TD\n    A[\"docker run -e PUID=1000 -e PGID=1000 image [cmd]\"]\n    A --\u003e B[\"Container starts\u003cbr/\u003eentrypoint.sh runs as root (PID 1)\"]\n    B --\u003e C{\"Setup\u003cbr/\u003eEUID == 0?\u003cbr/\u003e(running as root?)\"}\n    C -- No --\u003e ERR1[\"Error: EUID != 0, not running as root\u003cbr/\u003eexit 1\"]\n    C -- Yes --\u003e D{\"Process\u003cbr/\u003ePUID == 0\u003cbr/\u003eor PGID == 0?\"}\n    D -- Yes --\u003e ERR2[\"Error: root UID/GID not allowed\u003cbr/\u003eexit 1\"]\n    D -- No --\u003e E{\"Group 'steam'\u003cbr/\u003eexists?\"}\n    E -- No --\u003e F[\"groupadd steam --gid $PGID\"]\n    E -- Yes --\u003e G[\"Skip\"]\n    F --\u003e H{\"User 'steam'\u003cbr/\u003eexists?\"}\n    G --\u003e H\n    H -- No --\u003e I[\"useradd steam --uid $PUID\"]\n    H -- Yes --\u003e J[\"Skip\"]\n    I --\u003e K[\"chown -R steam:steam /home/steam\"]\n    J --\u003e K\n    K --\u003e L[\"exec gosu steam:steam [cmd]\"]\n    L --\u003e M[\"PID 1 is now [cmd]\u003cbr/\u003erunning as unprivileged user\"]\n    M --\u003e N[\"Root process is GONE\u003cbr/\u003eNo escalation path exists\"]\n```\n\n---\n\n## The security model explained\n\n### Why not just set `USER` in the Dockerfile?\n\nThe `USER` instruction in a Dockerfile sets a fixed user for all subsequent instructions and the final container process. It cannot react to runtime input — you cannot pass a `PUID`/`PGID` environment variable and have the `USER` instruction pick it up. You also cannot `chown` directories at runtime without root. This approach solves all of that.\n\n### Why `gosu` instead of `su` or `sudo`?\n\n| Tool | Behaviour | Problem |\n|---|---|---|\n| `sudo` | Forks a privileged helper process | Root process stays alive; `sudo` binary remains available as an attack surface |\n| `su` | Also forks; uses the system's login infrastructure (user authentication, session setup, environment loading) | All that overhead exists to serve interactive logins — none of it is needed or appropriate in a container; not designed for containers, and the root parent process still lingers |\n| `gosu` | `setuid` + `setgid` + `exec` in one step | No fork, no parent, no root remnant — purpose-built for this pattern |\n\n`gosu` does exactly three things: set the GID, set the UID, exec the command. After that, it no longer exists.\n\n### Why `exec gosu` and not just `gosu`?\n\nWithout `exec`, the shell running `entrypoint.sh` stays alive as PID 1 (as root), and `gosu` runs as a child process. The root shell is still there.\n\nWith `exec`, the shell **is replaced** by the process that `gosu` hands off to. The root shell is gone at the OS level — not just \"doing nothing\", but literally no longer a process.\n\n### Why does the shebang matter?\n\n`EUID` is introduced in the [What is EUID?](#what-is-euid) section above. The root check in `entrypoint.sh` relies on it — but that check only works correctly because the shebang is `#!/usr/bin/env bash`. Here is why that matters.\n\nIn `entrypoint.sh`, the guard is:\n\n```bash\nif [[ \"${EUID}\" -ne 0 ]]; then\n    echo \"\u003e\u003e\u003e [Entrypoint] Requires root to run setup (creating users, fixing file ownership).\"\n    echo \"    The container process is currently running as EUID=${EUID}. Please start the container without a --user override.\"\n    exit 1\nfi\n```\n\nThis only works correctly because the shebang is `#!/usr/bin/env bash`.\n\n`$EUID` is a **bash-specific read-only variable** — bash sets it automatically when the shell starts. If you were to change the shebang to `#!/usr/bin/env sh` (which resolves to `dash` on Debian/Ubuntu, or `busybox sh` on Alpine), `$EUID` would simply be empty. The comparison `\"\" -ne 0` would throw an integer error, or worse, silently evaluate in an unexpected way depending on the shell implementation. Either way, your guard is gone.\n\nThe same applies to `[[ ]]` (double-bracket conditionals) — these are a bash built-in and do not exist in POSIX `sh`. If you swap the shebang for `sh`, those comparisons break too.\n\n**Do not change the shebang.** The security checks only work in bash. If you change `bash` to `sh`, the checks stop working — and you will not get an error message telling you that. The script will just run as if the checks passed.\n\n### Guard rails\n\nTwo checks run before any setup work begins:\n\n**1. The container must be running as root.**\nThe entrypoint checks the `EUID` value — see [What is EUID?](#what-is-euid) above for what that means. If the check fails, the entrypoint exits immediately. This prevents silent failures from setup commands (`groupadd`, `useradd`, `chown`) being attempted without the necessary privileges.\n\n**2. `PUID` and `PGID` must not be `0`.**\nUID `0` and GID `0` are reserved by the OS for root. You cannot assign them to another user — `useradd` and `usermod` will either refuse outright or produce a second root-equivalent account, which breaks the system in ways that are hard to predict. Beyond that mechanical restriction, even if it somehow succeeded, the \"unprivileged\" process handed off by `gosu` would have root's UID and therefore root-level access. The entrypoint exits immediately if either value is zero so the misconfiguration is caught early with a clear error, rather than producing a container that silently runs as root.\n\n### The PUID / PGID pattern\n\nWhen you bind-mount a host directory into a container, the files are owned by a host UID/GID. If the container user has a different UID, it cannot write to those files. By accepting `PUID` and `PGID` as environment variables at runtime, the entrypoint creates the in-container user with exactly the right IDs to match host ownership — without hardcoding anything in the image.\n\n---\n\n## Environment variables\n\n| Variable | Default | Description |\n|---|---|---|\n| `PUID` | `7351` | UID to assign to the in-container user. Must not be `0`. |\n| `PGID` | `2431` | GID to assign to the in-container group. Must not be `0`. |\n\n---\n\n## Usage\n\n### Build\n\n```bash\n./docker-build.sh\n# or: docker build -t user-jail-mvp:local .\n```\n\n### Run with default IDs\n\n```bash\n./docker-run.sh [command]\n# or: docker run --rm user-jail-mvp:local [command]\n```\n\n### Run with custom IDs\n\n```bash\n./docker-run-other-ids.sh [command]\n# or: docker run --rm -e PUID=1000 -e PGID=1000 user-jail-mvp:local [command]\n```\n\n### Verify the jail\n\n```bash\n# Check the effective user identity — should show steam's UID, never root\n./docker-exec.sh\n# uid=7351(steam) gid=2431(steam) groups=2431(steam)\n\n# Open an interactive shell as the jailed user\n./docker-exec-ti.sh\n```\n\n### Testing both entrypoint paths\n\nThe `Dockerfile` contains a commented-out `RUN` block that pre-creates the `steam` user and group at build time. This lets you observe both code paths in `entrypoint.sh` without changing anything else:\n\n- **Uncomment** the block, rebuild, and run → entrypoint prints `Found group steam` / `Found user steam` and skips creation\n- **Comment it back out**, rebuild, and run → entrypoint prints `NOT found a group, creating it` / `NOT found a user, creating it`\n\nBoth paths converge at `exec gosu` and produce the same jailed result.\n\n---\n\n## Using this as a template\n\nThis repository is an **integration template**, not a base image to layer on top of blindly. The security guarantee only holds if the jail pattern in `entrypoint.sh` is preserved correctly.\n\nThe right way to use this:\n\n1. **Copy** this repository as your starting point\n2. Add your application's installation steps to the `Dockerfile` — these can run as root, that is intentional\n3. Keep `entrypoint.sh` as the `ENTRYPOINT` — extend it if you need extra setup steps, but always keep `exec gosu` as the **last line**. The `exec` keyword does not start a new process — it overwrites the running shell with your application. The shell that did the setup work ceases to exist; your application takes its place. Remove `exec` and the root shell survives as a silent, forgotten parent process\n4. Never add logic after the `exec gosu` line — it will never execute, and attempting to restructure it away from being the final `exec` breaks the jail\n5. Never change the shebang from `#!/usr/bin/env bash` to `sh` — as explained in [Why does the shebang matter?](#why-does-the-shebang-matter), the security checks are bash-specific and will silently stop working under any POSIX `sh` implementation\n\nThe entrypoint is the security boundary. Treat it as such.\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjammsen%2Fdocker-user-jail-mvp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjammsen%2Fdocker-user-jail-mvp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjammsen%2Fdocker-user-jail-mvp/lists"}