Chapter 27Lesson 02~155 minutes

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.

Non-root labBind mountsNumeric ownership--userEvidence

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 --user override 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.
Lab principle. Preserve the permission failure first, then repair only the exact disposable path with the identity actually proven by UID/GID-map evidence. Never use world-write as a shortcut.

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?

Why preserve the first bind-mount permission failure?

When is chown 10001:10001 an unsafe assumption?

Why avoid enabling userns-remap just for this lab?

What is a better shared-write strategy than world write?

Next lesson

Next: User Namespace Remapping, UID/GID Mapping, Non-Root Images, Filesystem Ownership, and Least Privilege: Configuration, Design Choices, and Tradeoffs

Continue with the next lesson in the course sequence and carry forward the evidence-first Docker operating model.

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.
Version/platform baseline, verified 2026-09-22.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.