User Namespace Remapping, UID/GID Mapping, Non-Root Images, Filesystem Ownership, and Least Privilege: Guided Hands-On Workflow and Core Operations
Build and run a non-root image, inspect numeric ownership across bind mounts, compare runtime user overrides, and preserve mapping evidence without broad permission fixes.
Learning objectives
- Build a small image whose declared runtime user is non-root and whose writable directory is intentionally owned.
-
Run the same image with an explicit numeric
--useroverride and observe changed permission behavior. - Use a disposable bind mount to prove host/container ownership and repair access with precise ownership/group changes rather than broad modes.
- Capture immutable image identity, numeric IDs, mount metadata, and first-failure evidence in a reproducible lab packet.
1. Lab boundary and assumptions
This mandatory lab uses only a disposable local directory, one tiny image, and exact container names. It does not reconfigure the daemon, enable userns-remap, alter subordinate ranges, use privileged mode, or change host security policy. The userns-remap comparison is read-only/conceptual unless you already have an authorized disposable daemon configured for it.
2. Preflight and evidence directory
set -eu
LAB="$HOME/da-ch27"
E="$LAB/evidence"
SRC="$LAB/src"
HOSTDATA="$LAB/hostdata"
mkdir -p "$E" "$SRC" "$HOSTDATA"
docker version > "$E/docker-version.txt"
docker context show > "$E/context.txt"
docker info --format 'Security={{json .SecurityOptions}} Root={{.DockerRootDir}}' > "$E/docker-info.txt"
id > "$E/host-id.txt"
stat -c 'hostdata %u:%g %a %n' "$HOSTDATA" > "$E/hostdata-before.txt" 2>/dev/null || true
3. Create a deliberately non-root image
The image uses a fixed numeric UID/GID so the ownership contract is inspectable even if host usernames differ. The package/add-user work occurs during build; the final process runs as UID/GID 10001.
cat > "$SRC/Dockerfile" <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.22.1
RUN addgroup -g 10001 app && adduser -D -H -u 10001 -G app app && mkdir -p /app /work && chown -R 10001:10001 /app /work
COPY --chown=10001:10001 message.txt /app/message.txt
USER 10001:10001
WORKDIR /app
CMD ["sh", "-c", "id; stat -c '%u:%g %a %n' /app/message.txt /work; cat /app/message.txt; sleep 3600"]
EOF
printf 'chapter-27
' > "$SRC/message.txt"
docker build --progress=plain -t da-ch27-app:lab "$SRC" | tee "$E/build.log"
docker image inspect da-ch27-app:lab --format 'ID={{.Id}} User={{json .Config.User}}' | tee "$E/image.txt"
4. First run: prove image-time ownership before any host mount
docker run -d --name da-ch27-base --label academy.chapter=27 da-ch27-app:lab
docker logs da-ch27-base | tee "$E/base.log"
docker exec da-ch27-base sh -c 'id; stat -c "%u:%g %a %n" /app/message.txt /work; cat /proc/self/uid_map; cat /proc/self/gid_map' | tee "$E/base-identity.txt"
docker inspect da-ch27-base --format 'User={{json .Config.User}} Mounts={{json .Mounts}}' | tee "$E/base-inspect.txt"
The configured user should be 10001:10001 and the image-owned writable directory should match it. The UID/GID map reveals whether the daemon uses a user namespace.
5. Intentionally broken bind mount: preserve the permission failure
Create a root/host-user-owned directory with restrictive permissions. Do not “fix” it first.
printf 'host-owned
' > "$HOSTDATA/input.txt"
chmod 0755 "$HOSTDATA"
chmod 0644 "$HOSTDATA/input.txt"
stat -c '%u:%g %a %n' "$HOSTDATA" "$HOSTDATA/input.txt" | tee "$E/hostdata-owned.txt"
docker run --rm --name da-ch27-fail --mount type=bind,src="$HOSTDATA",dst=/work da-ch27-app:lab sh -c 'id; stat -c "%u:%g %a %n" /work; echo attempt > /work/from-container.txt' >"$E/fail.stdout" 2>"$E/fail.stderr" || true
cat "$E/fail.stderr"
The failure is expected on a normal rootful daemon if the directory is not writable by UID 10001. Under userns/rootless mappings, the host-visible ID can differ further. Preserve the error and mapping evidence before changing ownership.
6. Repair precisely: ownership or group, not world write
On an ordinary non-remapped Linux daemon, one precise lab fix is to assign the disposable directory to UID/GID 10001. This changes only the lab path. If the daemon uses userns-remap/rootless, calculate the correct host-side mapped ID from the actual UID map instead of blindly applying 10001.
# Only for the disposable lab directory and only if your mapping evidence says host 10001:10001 is correct.
if ! docker info --format '{{json .SecurityOptions}}' | grep -Eq 'rootless|name=userns'; then
chown -R 10001:10001 "$HOSTDATA" 2>/dev/null || true
fi
stat -c '%u:%g %a %n' "$HOSTDATA" | tee "$E/hostdata-after-ownership.txt"
docker run --rm --name da-ch27-write --mount type=bind,src="$HOSTDATA",dst=/work da-ch27-app:lab sh -c 'id; echo ok > /work/from-container.txt; stat -c "%u:%g %a %n" /work/from-container.txt'
stat -c '%u:%g %a %n' "$HOSTDATA/from-container.txt" | tee "$E/host-created.txt" 2>/dev/null || true
If host chown is not permitted or not appropriate, a
group-based design or Docker-managed volume may be better. The
lesson is the mapping, not a mandatory ownership mutation.
7. Runtime --user changes process identity but not existing image ownership
docker run \
--rm \
--user 20002:20002 da-ch27-app:lab sh -c 'id; stat -c "%u:%g %a %n" /app/message.txt /work; test -w /work && echo writable || echo not-writable' | tee "$E/user-override.txt"
The override changes the process UID/GID. It does not rewrite
ownership of files baked into the image. This is why “just add
--user” often turns into a permission failure rather
than a least-privilege solution.
8. Optional userns-remap / rootless comparison without daemon mutation
docker info --format '{{json .SecurityOptions}}' | tee "$E/security-options.json"
docker exec da-ch27-base cat /proc/self/uid_map | tee "$E/uid-map.txt"
docker exec da-ch27-base cat /proc/self/gid_map | tee "$E/gid-map.txt"
grep "^$(whoami):" /etc/subuid 2>/dev/null | tee "$E/subuid.txt" || true
grep "^$(whoami):" /etc/subgid 2>/dev/null | tee "$E/subgid.txt" || true
If you already have a separate rootless context, repeat only the identity inspection there. If you already have an authorized disposable userns-remap daemon, record its mappings. Do not enable remapping on your normal populated daemon merely to complete this lesson.
9. Challenge: choose the failing layer
A container built with USER 10001 can write its image
directory but cannot write a restored named volume. Should you
rebuild the image, change the network, run as root, or inspect
storage ownership/mapping first?
Correct layer: storage/identity. Capture volume mount metadata, numeric ownership, runtime UID/GID, supplementary groups, and user-namespace mode. Only then choose image-time ownership, initialization, or group-based access.
10. Exact cleanup
docker rm -f da-ch27-base 2>/dev/null || true
docker image rm da-ch27-app:lab 2>/dev/null || true
# Keep $HOME/da-ch27/evidence for review. Remove only the exact lab tree when you no longer need it:
# rm -rf "$HOME/da-ch27"
No volume prune, daemon reset, subordinate-ID edit, or broad filesystem cleanup is required.
Knowledge check
Why does --user 20002:20002 not make image files
owned by 20002?
Runtime user overrides process credentials; it does not rewrite file ownership already stored in image layers.
Why preserve the first bind-mount permission failure?
It proves the original mismatch and prevents a broad permission change from hiding the actual identity/storage cause.
When is chown 10001:10001 an unsafe
assumption?
When the daemon uses userns-remap/rootless mapping, when the host path is not a disposable lab path, or when another ownership/group model is required.
Why avoid enabling userns-remap just for this lab?
It is a daemon-wide storage/identity change that can mask existing Docker objects and requires migration planning.
What is a better shared-write strategy than world write?
A deliberate shared GID, group-write directory, and supplementary-group configuration where appropriate.
Official references and version notes
- User namespace remapping — current prerequisites, mapping behavior, daemon configuration, limitations, and migration cautions.
- Rootless UID/GID mapping — precise difference between userns-remap and rootless host-ID translation.
- Rootless mode — daemon-level non-root execution and subordinate-ID prerequisites.
- Dockerfile reference — USER and COPY --chown semantics, including numeric versus name lookup.
- Compose services reference — service user override and user namespace configuration surface.
- Bind mounts — host-path ownership, read/write authority, and daemon-host path semantics.
- Volumes — Docker-managed persistent storage and container mount behavior.
- Docker Engine 29 release notes — current Engine baseline and user-namespace-related fixes/changes.
Docker Engine 29.8.1 is the current Engine release. ' Docker
documents two different mappings: in userns-remap,
container UID/GID 0 maps to the first subordinate ID and ID
n maps to subordinate-start + n; in rootless mode,
container UID/GID 0 maps to the host user and IDs ≥1 map into the
subordinate range with an offset. ' Docker recommends enabling
userns-remap on a new daemon because existing Docker
objects become masked by the remapped storage layout, and it warns
that host-mounted filesystem ownership must be arranged for the
mapped IDs. ' COPY --chown accepts names or numeric
IDs; names require matching /etc/passwd//etc/group
inside the build rootfs, while numeric IDs do not. ' Compose
user overrides the image-configured user. Every lab
therefore records the actual Engine/context/security mode and
numeric UID/GID evidence instead of assuming usernames imply
equivalent authority.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.